Compare commits
24 Commits
9783657da3
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a7850a0296 | |||
| ec3b0e26fd | |||
| 19d1a56df4 | |||
| 3934bdf814 | |||
| dbd44ecc20 | |||
| 93687a32fe | |||
| 2d9fec3a6d | |||
| a6f4ca88a4 | |||
| 297eff949e | |||
| 01e2e0fc4b | |||
| b4043b20b2 | |||
| ad61fd3779 | |||
| d1b55cd65b | |||
| cb46372e52 | |||
| f1814fe8ec | |||
| 12a9fe1810 | |||
| 8b5b9905a7 | |||
| cd23ebc3ba | |||
| f30ac73b79 | |||
| bb85ee2e27 | |||
| 0d5ebcd27a | |||
| 7d721a6787 | |||
| 9a1ad87acd | |||
| 911697bab4 |
@@ -41,6 +41,11 @@ backups/*.backup
|
||||
*.mp3
|
||||
*.wav
|
||||
|
||||
# Cloned external legal-source repos (gitignored; pulled fresh at ingest time)
|
||||
legal-sources/bsi-quaidal/
|
||||
legal-sources/bsi-quaidal-src/
|
||||
legal-sources/bsi-grundschutz-plus/
|
||||
|
||||
# Compiled binaries
|
||||
billing-service/billing-service
|
||||
consent-service/server
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
# Lessons Learned — MC `check_type` Klassifikation (KRITISCH fuer CRA + alle neuen Frameworks)
|
||||
|
||||
Datum: 2026-05-17
|
||||
Auslöser: Compliance-Check BMW lieferte 0/381 Cookie-MCs, 3/75 Impressum-MCs, 43/571 DSE-MCs — alle Doc-Typen unter 20%.
|
||||
|
||||
## TL;DR
|
||||
|
||||
**Die heutigen Master-Controls (MCs) vermischen drei strukturell unterschiedliche Klassen von Pruefungen in einer einzigen Tabelle (`compliance.doc_check_controls`). Nur eine der drei Klassen lässt sich gegen Dokument-Text matchen. Die anderen zwei werden faelschlich als "failed" gezaehlt, weil sie ueberhaupt nicht ueber Text-Matching gepruefbar sind.**
|
||||
|
||||
Bei der CRA-MC-Generierung (laeuft jetzt im Pass 0a mit Haiku) **MUSS** jeder MC ein **`check_type`-Feld** bekommen, bevor er in die Datenbank geht. Sonst wiederholt sich das Problem.
|
||||
|
||||
## Die drei Klassen
|
||||
|
||||
| `check_type` | Pruefungsfrage-Pattern | Beispiel | Wie pruefbar? |
|
||||
|---|---|---|---|
|
||||
| **`text`** | "Enthaelt das Dokument...", "Wird im X die Y benannt?", "Ist im Text aufgelistet..." | "Wird im Impressum die Aufsichtsbehoerde benannt?" | Regex / Embedding-Match gegen Doc-Text |
|
||||
| **`process`** | "Ist sichergestellt...", "Ist implementiert...", "Wird durchgefuehrt..." | "Ist sichergestellt, dass Cookies erst nach Einwilligung gespeichert werden?" | Evidence/TOM-Check — kein Doc-Text vorhanden |
|
||||
| **`review`** | "Sind ALLE / Werden ALLE / Stimmt X mit Y ueberein?" | "Sind alle Verarbeitungszwecke vollstaendig erfasst?" | Mensch (DSB) — Checkliste, nicht automatisch |
|
||||
|
||||
## Befund aus den BMW-Daten
|
||||
|
||||
| Doc-Type | TEXT (matchbar) | PROCESS | UNKLAR/REVIEW | Total | % TEXT |
|
||||
|---|---|---|---|---|---|
|
||||
| cookie | 30 | 49 | 302 | 381 | **8%** |
|
||||
| dse | 72 | 139 | 359 | 571 | **13%** |
|
||||
| impressum | 14 | 14 | 47 | 75 | **19%** |
|
||||
| agb | 24 | 20 | 69 | 113 | 21% |
|
||||
| widerruf | 29 | 26 | 96 | 153 | 19% |
|
||||
| loeschkonzept | 38 | 39 | 232 | 309 | 12% |
|
||||
|
||||
**Selbst mit perfektem Matching liegt die Obergrenze fuer doc_check bei 8-20%**, weil 80-92% der MCs nicht ueber Text-Matching pruefbar sind. Es sind keine "schlechten MCs" — sie sind in der falschen Schublade.
|
||||
|
||||
## Konsequenzen fuer CRA-Generation (Pass 0a)
|
||||
|
||||
### 1. Prompt-Aenderung (Hauptmassnahme)
|
||||
|
||||
Der Pass-0a-Prompt fuer Haiku/Sonnet MUSS pro generiertem Control ein `check_type`-Feld erzwingen. Vorschlag:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"control_id": "CRA-...-A01",
|
||||
"title": "...",
|
||||
"check_question": "...",
|
||||
"check_type": "text" | "process" | "review", // PFLICHT
|
||||
"rationale_for_check_type": "..."
|
||||
}
|
||||
```
|
||||
|
||||
Klassifikations-Regel im Prompt:
|
||||
|
||||
> Wenn deine `check_question` mit "Enthaelt", "Wird … genannt/aufgelistet/erwaehnt", "Steht im Text" beginnt -> `text`.
|
||||
> Wenn sie mit "Ist sichergestellt", "Ist implementiert", "Wird durchgefuehrt", "Existiert ein Prozess" beginnt -> `process`.
|
||||
> Wenn sie mit "Sind ALLE", "Werden ALLE", "Stimmt X mit Y ueberein" beginnt -> `review`.
|
||||
> Im Zweifel: lieber `review` als `text`.
|
||||
|
||||
### 2. Doc-Type-Zuordnung kritisch validieren
|
||||
|
||||
Bei den heutigen MCs sind viele falsch zugeordnet (z.B. "Bestellbestätigung implementieren" landet im `impressum`-doc_type, gehoert aber zu AGB/Widerruf). Fuer CRA:
|
||||
|
||||
- **`doc_type` darf nur Werte aus einer expliziten Liste annehmen** — pro Regulation festlegen.
|
||||
- Fuer CRA z.B.: `produkt_konformitaetserklaerung`, `risiko_management_dossier`, `sbom`, `cra_dse`, `meldepflichten_doku`.
|
||||
- Falsche Zuordnung im Prompt explizit verbieten: "Wenn der Control nicht eindeutig zu EINEM dieser Doc-Typen passt, setze `doc_type: 'unassigned'` und `check_type: 'review'`."
|
||||
|
||||
### 3. Zwei Tabellen statt einer
|
||||
|
||||
Heutige Architektur:
|
||||
- `compliance.doc_check_controls` <- alle 1874 MCs (mit allem vermischt)
|
||||
|
||||
Empfohlen fuer CRA + Refactor:
|
||||
- `compliance.text_check_controls` <- nur `check_type='text'`
|
||||
- `compliance.process_check_controls` <- nur `check_type='process'`, gepruefte via Evidence/TOM
|
||||
- `compliance.review_checklist_controls` <- nur `check_type='review'`, gepruefte via DSB-Workflow
|
||||
|
||||
Falls Schema-Aenderung nicht moeglich (CLAUDE.md: DB ist frozen), Sidecar-SQLite mit `mc_classification.db` oder neue Spalte als Add-only-Migration.
|
||||
|
||||
### 4. Dedupe-Phase respektieren
|
||||
|
||||
In Pass 0b (Dedup) muss `check_type` ein **Pflicht-Dedupe-Key** sein:
|
||||
- Zwei MCs mit gleicher Aussage aber unterschiedlichem `check_type` sind **nicht** Duplikate — sie pruefen verschiedene Dinge ("ist im Text genannt" vs "ist technisch implementiert").
|
||||
- Heute werden solche faelschlich gemerged → noch mehr Vermischung.
|
||||
|
||||
### 5. Matching-Engine danach umbauen
|
||||
|
||||
Das eigentliche doc-check-Match-System muss nur noch `check_type='text'`-MCs verarbeiten. Andere werden in ihre eigenen Module geroutet:
|
||||
|
||||
- `text` MCs -> `rag_document_checker` (Regex + spaeter Embedding)
|
||||
- `process` MCs -> neuer `evidence_check_runner` (Kunde lieferte Nachweise/TOM hoch)
|
||||
- `review` MCs -> neuer `review_checklist_ui` (DSB beantwortet manuell)
|
||||
|
||||
## Checkliste fuer CRA-Session
|
||||
|
||||
- [ ] Pass-0a-Prompt um `check_type`-Pflichtfeld erweitert (Wortlaut-Regel + Beispiele)
|
||||
- [ ] Pass-0a-Prompt zwingt `doc_type` aus expliziter Whitelist
|
||||
- [ ] Pass-0b-Dedup-Key enthaelt `check_type`
|
||||
- [ ] Output-Validator weist MCs ohne `check_type` zurueck
|
||||
- [ ] DB-Schema (oder Sidecar) hat `check_type`-Spalte mit Default `review` (sicherer Fallback)
|
||||
- [ ] Stichprobe von 50 generierten CRA-MCs vor Bulk-Run: TEXT-Anteil sollte 30-50% sein (mehr als bei den alten DSGVO-MCs, weil CRA stark dokument-fokussiert ist).
|
||||
|
||||
## Update 2026-05-17 — Parallel-CRA-Session-Findings
|
||||
|
||||
Die laufende CRA-Generation hat ein Feld `verification_method` (document/tool/hybrid/code_review/empty), das **NICHT identisch** mit `check_type` ist:
|
||||
|
||||
- `verification_method` fragt: **WAS schaust du dir an?** (Dokument, Tool-Output, Code)
|
||||
- `check_type` fragt: **KANN das per Text-Match geprueft werden?** (text/process/review)
|
||||
|
||||
Ein Control kann `verification_method=document` haben UND trotzdem `check_type=process` sein. Beispiel: "Wird die SBOM regelmaessig (mindestens monatlich) aktualisiert?" — Du schaust ins Dokument SBOM-Historie, prüfst aber einen Prozess. Text-Match findet das nie.
|
||||
|
||||
**Mapping-Heuristik (gut genug fuer 80% der Faelle, Rest LLM):**
|
||||
|
||||
| `verification_method` | Auto-Mapping `check_type` | LLM noetig? |
|
||||
|---|---|---|
|
||||
| `tool` | `process` | nein |
|
||||
| `code_review` | `process` | nein |
|
||||
| empty/null | `review` (sicherer Default) | nein |
|
||||
| `document` | erstmal `text`, Stichprobe pruefen | 10-20% sampling |
|
||||
| `hybrid` | LLM klassifizieren | ja, alle |
|
||||
|
||||
**Idealfall (fuer alle KUENFTIGEN Pass-0a-Generationen — auch CRA falls man nochmal generiert):** Beide Felder gleichzeitig generieren, nicht eins aus dem anderen ableiten.
|
||||
|
||||
## Backfill-Workflow fuer die laufende CRA-Generation
|
||||
|
||||
1. Aktueller Haiku-Job laeuft fertig (kein Restart, kein Verlust)
|
||||
2. Nach Job-Ende: Auto-Mapping fuer eindeutige Buckets (tool/code_review/empty)
|
||||
3. Sonnet-Klassifikation nur fuer `document`+`hybrid` Subset (~62 Calls fuer 1500 Controls, ~$0.05 statt $2)
|
||||
4. Wiederverwenden: `breakpilot-compliance/backend-compliance/scripts/classify_mc_check_type.py` — nur DB-Query anpassen (Source-Tabelle + WHERE-Filter)
|
||||
5. Validierung: TEXT-Anteil bei CRA sollte 40-60% sein (CRA ist dokument-zentrierter als DSGVO-Cookie)
|
||||
|
||||
## Quervewweise
|
||||
|
||||
- BMW-Run-Befund: `breakpilot-compliance` E-Mail vom 2026-05-17, check_id `08bcc9dd`
|
||||
- Bestehender Klassifikations-Skript fuer Retrofit der alten 1874: `backend-compliance/scripts/classify_mc_check_type.py`
|
||||
- Doc-Type-Audit-Query: dieselbe Datei, am Ende
|
||||
@@ -0,0 +1,430 @@
|
||||
source: Derived from BSI QUAIDAL (Clean-Room)
|
||||
source_url: https://github.com/BSI-Bund/QUAIDAL
|
||||
commit_sha: c39b75369841b359c6bf56d6588e3768c722842f
|
||||
plagiarism_limit_4gram: 0.2
|
||||
generated_by_model: qwen3.5:35b-a3b
|
||||
controls:
|
||||
- id: AC-AI-DATA-QB-01-syntaktische-genauigkeit
|
||||
canonical_name: Syntaktische Genauigkeit
|
||||
description: Das KI-Trainingsset muss syntaktisch konsistent sein, wobei alle definierten
|
||||
Grammatik- und Strukturregeln strikt einzuhalten sind. Eine fehlerfreie Datenstruktur
|
||||
ist zwingend erforderlich, um eine korrekte Verarbeitung durch Parser oder Sprachmodelle
|
||||
zu gewährleisten. Die Validierung der formalen Korrektheit ist vor jedem Training
|
||||
durchzuführen, um Verarbeitungsfehler auszuschließen.
|
||||
kind: building_block
|
||||
regulation_anchor: EU AI Act Art. 10 (Datenqualität für Hochrisiko-KI)
|
||||
related_quaidal_ids:
|
||||
- MA-01
|
||||
- MA-02
|
||||
- MA-03
|
||||
- MA-04
|
||||
- MA-05
|
||||
- MA-27
|
||||
external_refs:
|
||||
- framework: BSI AIC4
|
||||
citation: null
|
||||
- framework: ISO/IEC 25012
|
||||
citation: null
|
||||
source:
|
||||
framework: BSI QUAIDAL
|
||||
section: QB-01
|
||||
title_original_de: QB-01 Syntaktische Genauigkeit
|
||||
url: https://github.com/BSI-Bund/QUAIDAL/blob/main/0000_Markdown/0001_Criteria,Measurements,Metrics/0001_Qualitätsbausteine/QB-01_Syntactic%20Accuracy.md
|
||||
commit_sha: c39b75369841b359c6bf56d6588e3768c722842f
|
||||
license_note: § 5 UrhG anwendbar; share:true im Frontmatter; Clean-Room-Ableitung.
|
||||
plagiarism_score_at_generation: 0.0
|
||||
- id: AC-AI-DATA-QB-02-semantische-genauigkeit
|
||||
canonical_name: Semantische Genauigkeit
|
||||
description: Die KI-Trainingsdaten müssen inhaltlich korrekt sein, sodass die zugewiesenen
|
||||
Werte dem tatsächlichen Sachverhalt entsprechen und nicht nur formal valide sind.
|
||||
Es ist sicherzustellen, dass semantische Zuordnungen keine logischen Fehler aufweisen,
|
||||
wie beispielsweise die Klassifizierung von Tieren als technische Geräte. Eine
|
||||
Prüfung muss verifizieren, dass die Bedeutung der Datenpunkte im Kontext der Anwendung
|
||||
eindeutig und fehlerfrei interpretiert werden kann.
|
||||
kind: building_block
|
||||
regulation_anchor: EU AI Act Art. 10 (Datenqualität für Hochrisiko-KI)
|
||||
related_quaidal_ids:
|
||||
- MA-05
|
||||
- MA-06
|
||||
- MA-07
|
||||
- MA-27
|
||||
external_refs:
|
||||
- framework: BSI AIC4
|
||||
citation: null
|
||||
source:
|
||||
framework: BSI QUAIDAL
|
||||
section: QB-02
|
||||
title_original_de: QB-02 Semantische Genauigkeit
|
||||
url: https://github.com/BSI-Bund/QUAIDAL/blob/main/0000_Markdown/0001_Criteria,Measurements,Metrics/0001_Qualitätsbausteine/QB-02_Semantic%20Accuracy.md
|
||||
commit_sha: c39b75369841b359c6bf56d6588e3768c722842f
|
||||
license_note: § 5 UrhG anwendbar; share:true im Frontmatter; Clean-Room-Ableitung.
|
||||
plagiarism_score_at_generation: 0.0
|
||||
- id: AC-AI-DATA-QB-03-vielfalt
|
||||
canonical_name: Vielfalt
|
||||
description: Das KI-Trainingsdatenset muss eine maximale Varianz in den relevanten
|
||||
Merkmalen aufweisen, um die Heterogenität der Eingabewerte zu gewährleisten. Es
|
||||
ist sicherzustellen, dass das Spektrum der enthaltenen Werte breit genug ist,
|
||||
um das Variationspotential der Zielgruppe vollständig abzudecken. Eine Prüfung
|
||||
der Datenverteilung ist vor dem Training durchzuführen, um eine unzureichende
|
||||
Diversität auszuschließen.
|
||||
kind: building_block
|
||||
regulation_anchor: EU AI Act Art. 10 (Datenqualität für Hochrisiko-KI)
|
||||
related_quaidal_ids:
|
||||
- MA-08
|
||||
- MA-09
|
||||
- MA-10
|
||||
- MA-12
|
||||
- MA-27
|
||||
- MA-28
|
||||
external_refs: []
|
||||
source:
|
||||
framework: BSI QUAIDAL
|
||||
section: QB-03
|
||||
title_original_de: QB-03 Vielfalt
|
||||
url: https://github.com/BSI-Bund/QUAIDAL/blob/main/0000_Markdown/0001_Criteria,Measurements,Metrics/0001_Qualitätsbausteine/QB-03_Diversity.md
|
||||
commit_sha: c39b75369841b359c6bf56d6588e3768c722842f
|
||||
license_note: § 5 UrhG anwendbar; share:true im Frontmatter; Clean-Room-Ableitung.
|
||||
plagiarism_score_at_generation: 0.0204
|
||||
- id: AC-AI-DATA-QB-04-ausgewogenheit
|
||||
canonical_name: Ausgewogenheit
|
||||
description: Der Trainingsdatensatz ist so zu konzipieren, dass die Verteilung aller
|
||||
relevanten Klassen proportional zur Zielrealität erfolgt, um eine einseitige Dominanz
|
||||
einzelner Kategorien zu vermeiden. Es ist sicherzustellen, dass keine Gruppe systematisch
|
||||
unter- oder überrepräsentiert wird, um Verzerrungen im Modellverhalten auszuschließen.
|
||||
Die Datenqualität muss durch eine ausgewogene Varianz aller Merkmale gewährleistet
|
||||
werden, um Overfitting und Bias wirksam zu verhindern.
|
||||
kind: building_block
|
||||
regulation_anchor: EU AI Act Art. 10 (Datenqualität für Hochrisiko-KI)
|
||||
related_quaidal_ids:
|
||||
- MA-08
|
||||
- MA-09
|
||||
- MA-10
|
||||
- MA-12
|
||||
- MA-14
|
||||
- MA-27
|
||||
external_refs: []
|
||||
source:
|
||||
framework: BSI QUAIDAL
|
||||
section: QB-04
|
||||
title_original_de: QB-04 Ausgewogenheit
|
||||
url: https://github.com/BSI-Bund/QUAIDAL/blob/main/0000_Markdown/0001_Criteria,Measurements,Metrics/0001_Qualitätsbausteine/QB-04_Balance.md
|
||||
commit_sha: c39b75369841b359c6bf56d6588e3768c722842f
|
||||
license_note: § 5 UrhG anwendbar; share:true im Frontmatter; Clean-Room-Ableitung.
|
||||
plagiarism_score_at_generation: 0.0182
|
||||
- id: AC-AI-DATA-QB-05-umfang
|
||||
canonical_name: Umfang
|
||||
description: Der Trainingsdatensatz muss eine quantitativ ausreichende Anzahl an
|
||||
Datenpunkten aufweisen, um statistisch signifikante Muster zu erfassen und das
|
||||
Risiko von Overfitting zu minimieren. Die Größe der Datenbasis ist so zu dimensionieren,
|
||||
dass sie eine belastbare Analyse der zugrundeliegenden Verteilungen ermöglicht
|
||||
und die Generalisierungsfähigkeit des Modells stabilisiert. Eine Prüfung ist durchzuführen,
|
||||
um sicherzustellen, dass der reine quantitative Umfang die notwendige Basis für
|
||||
eine robuste Modellbildung bildet.
|
||||
kind: building_block
|
||||
regulation_anchor: EU AI Act Art. 10 (Datenqualität für Hochrisiko-KI)
|
||||
related_quaidal_ids:
|
||||
- MA-11
|
||||
- MA-12
|
||||
- MA-15
|
||||
- MA-27
|
||||
external_refs:
|
||||
- framework: BSI AIC4
|
||||
citation: null
|
||||
source:
|
||||
framework: BSI QUAIDAL
|
||||
section: QB-05
|
||||
title_original_de: QB-05 Umfang
|
||||
url: https://github.com/BSI-Bund/QUAIDAL/blob/main/0000_Markdown/0001_Criteria,Measurements,Metrics/0001_Qualitätsbausteine/QB-05_Size.md
|
||||
commit_sha: c39b75369841b359c6bf56d6588e3768c722842f
|
||||
license_note: § 5 UrhG anwendbar; share:true im Frontmatter; Clean-Room-Ableitung.
|
||||
plagiarism_score_at_generation: 0.0161
|
||||
- id: AC-AI-DATA-QB-06-verzerrung
|
||||
canonical_name: Verzerrung
|
||||
description: Das KI-System muss vor dem produktiven Einsatz auf systematische Verzerrungen
|
||||
in den Trainingsdaten und den daraus resultierenden Vorhersagen untersucht werden.
|
||||
Es ist sicherzustellen, dass latente Ungleichbehandlungen quantitativ erfasst
|
||||
und dokumentiert werden, um eine transparente Bewertung der Fairness zu ermöglichen.
|
||||
Die Prüfung umfasst die Identifikation von Abweichungen, die auf unausgewogene
|
||||
Datenverteilungen zurückzuführen sind, bevor das Modell für reale Anwendungen
|
||||
freigegeben wird.
|
||||
kind: building_block
|
||||
regulation_anchor: EU AI Act Art. 10 (Datenqualität für Hochrisiko-KI)
|
||||
related_quaidal_ids:
|
||||
- MA-01
|
||||
- MA-02
|
||||
- MA-03
|
||||
- MA-04
|
||||
- MA-06
|
||||
- MA-07
|
||||
- MA-08
|
||||
- MA-09
|
||||
- MA-10
|
||||
- MA-11
|
||||
- MA-12
|
||||
- MA-13
|
||||
- MA-14
|
||||
- MA-15
|
||||
- MA-16
|
||||
- MA-17
|
||||
- MA-18
|
||||
- MA-20
|
||||
- MA-23
|
||||
- MA-24
|
||||
- MA-27
|
||||
- MA-28
|
||||
- QB-15
|
||||
- QM-11
|
||||
external_refs: []
|
||||
source:
|
||||
framework: BSI QUAIDAL
|
||||
section: QB-06
|
||||
title_original_de: QB-06 Verzerrung
|
||||
url: https://github.com/BSI-Bund/QUAIDAL/blob/main/0000_Markdown/0001_Criteria,Measurements,Metrics/0001_Qualitätsbausteine/QB-06_Bias-Detektion.md
|
||||
commit_sha: c39b75369841b359c6bf56d6588e3768c722842f
|
||||
license_note: § 5 UrhG anwendbar; share:true im Frontmatter; Clean-Room-Ableitung.
|
||||
plagiarism_score_at_generation: 0.0
|
||||
- id: AC-AI-DATA-QB-07-gesamtheit
|
||||
canonical_name: Gesamtheit
|
||||
description: Das Trainingsdatenset muss sämtliche für das spezifische Anwendungsszenario
|
||||
definierten Attribute und Entitätsinstanzen vollständig enthalten, um die Anforderung
|
||||
der Gesamtheit zu erfüllen. Diese Vollständigkeit ist auf der Ebene des gesamten
|
||||
Datensatzes, einzelner Spalten oder einzelner Datenpunkte nachweisbar zu prüfen.
|
||||
Die Bewertung der Datenqualität erfolgt stets kontextbezogen unter Berücksichtigung
|
||||
der jeweiligen Nutzungszwecke.
|
||||
kind: building_block
|
||||
regulation_anchor: EU AI Act Art. 10 (Datenqualität für Hochrisiko-KI)
|
||||
related_quaidal_ids:
|
||||
- MA-12
|
||||
- MA-13
|
||||
- MA-27
|
||||
external_refs: []
|
||||
source:
|
||||
framework: BSI QUAIDAL
|
||||
section: QB-07
|
||||
title_original_de: QB-07 Gesamtheit
|
||||
url: https://github.com/BSI-Bund/QUAIDAL/blob/main/0000_Markdown/0001_Criteria,Measurements,Metrics/0001_Qualitätsbausteine/QB-07_Totality.md
|
||||
commit_sha: c39b75369841b359c6bf56d6588e3768c722842f
|
||||
license_note: § 5 UrhG anwendbar; share:true im Frontmatter; Clean-Room-Ableitung.
|
||||
plagiarism_score_at_generation: 0.0
|
||||
- id: AC-AI-DATA-QB-08-konsistenzsicherung
|
||||
canonical_name: Konsistenzsicherung
|
||||
description: Die Konsistenz der KI-Trainingsdaten ist durch standardisierte Datentypen
|
||||
und formatierte Attribute über den gesamten Lebenszyklus sicherzustellen. Automatisierte
|
||||
Prüfmechanismen müssen Abweichungen in den Datenwerten sowie zeitlichen Verläufen
|
||||
frühzeitig identifizieren, um nachvollziehbare Transformations- oder Imputationsmaßnahmen
|
||||
einzuleiten. Eine einheitliche Datenstruktur ist zwingend erforderlich, um die
|
||||
Integrität der Trainingsbasis für valide Modellentscheidungen zu gewährleisten.
|
||||
kind: building_block
|
||||
regulation_anchor: EU AI Act Art. 10 (Datenqualität für Hochrisiko-KI)
|
||||
related_quaidal_ids:
|
||||
- MA-01
|
||||
- MA-02
|
||||
- MA-03
|
||||
external_refs:
|
||||
- framework: ISO/IEC 25012
|
||||
citation: null
|
||||
- framework: BSI AIC4
|
||||
citation: null
|
||||
source:
|
||||
framework: BSI QUAIDAL
|
||||
section: QB-08
|
||||
title_original_de: QB-08 Konsistenzsicherung
|
||||
url: https://github.com/BSI-Bund/QUAIDAL/blob/main/0000_Markdown/0001_Criteria,Measurements,Metrics/0001_Qualitätsbausteine/QB-08_ConsistencyAssurance.md
|
||||
commit_sha: c39b75369841b359c6bf56d6588e3768c722842f
|
||||
license_note: § 5 UrhG anwendbar; share:true im Frontmatter; Clean-Room-Ableitung.
|
||||
plagiarism_score_at_generation: 0.0
|
||||
- id: AC-AI-DATA-QB-09-quellenmanagement
|
||||
canonical_name: Quellenmanagement
|
||||
description: Die Organisation muss einen durchgängigen Mechanismus implementieren,
|
||||
der die Herkunft und den Verarbeitungsweg jeder Trainingsdaten-Einheit lückenlos
|
||||
dokumentiert. Es ist sicherzustellen, dass jeder Datenpunkt mit seinem Ursprung
|
||||
sowie allen nachfolgenden Transformationsschritten verknüpft bleibt, um die Integrität
|
||||
der KI-Datenbasis zu gewährleisten. Zusätzlich sind alle Zugriffe und Modifikationen
|
||||
in einem unveränderlichen Protokoll chronologisch festzuhalten, um einen vollständigen
|
||||
Audit-Trail für Compliance-Prüfungen zu schaffen.
|
||||
kind: building_block
|
||||
regulation_anchor: EU AI Act Art. 10 (Datenqualität für Hochrisiko-KI)
|
||||
related_quaidal_ids:
|
||||
- MA-18
|
||||
- MA-19
|
||||
- MA-20
|
||||
- MA-22
|
||||
external_refs:
|
||||
- framework: BSI AIC4
|
||||
citation: null
|
||||
- framework: AI Act
|
||||
citation: null
|
||||
source:
|
||||
framework: BSI QUAIDAL
|
||||
section: QB-09
|
||||
title_original_de: QB-09 Quellenmanagement
|
||||
url: https://github.com/BSI-Bund/QUAIDAL/blob/main/0000_Markdown/0001_Criteria,Measurements,Metrics/0001_Qualitätsbausteine/QB-09_Sourcemanagement.md
|
||||
commit_sha: c39b75369841b359c6bf56d6588e3768c722842f
|
||||
license_note: § 5 UrhG anwendbar; share:true im Frontmatter; Clean-Room-Ableitung.
|
||||
plagiarism_score_at_generation: 0.0167
|
||||
- id: AC-AI-DATA-QB-10-datenpruefung
|
||||
canonical_name: _Datenprüfung
|
||||
description: Vor der Initialisierung des Trainingsprozesses ist eine systematische
|
||||
Validierung der Eingangsdaten auf Vollständigkeit, Konsistenz und Integrität durchzuführen.
|
||||
Dabei sind Unregelmäßigkeiten wie fehlende Werte, formatinkonsistenzen oder statistische
|
||||
Ausreißer zu identifizieren und zu bereinigen. Das System muss sicherstellen,
|
||||
dass keine verzerrten oder fehlerhaften Datensätze das Modelltraining beeinträchtigen
|
||||
und die Datenqualität den definierten Qualitätsstandards entspricht.
|
||||
kind: building_block
|
||||
regulation_anchor: EU AI Act Art. 10 (Datenqualität für Hochrisiko-KI)
|
||||
related_quaidal_ids:
|
||||
- MA-05
|
||||
- MA-20
|
||||
- MA-26
|
||||
external_refs: []
|
||||
source:
|
||||
framework: BSI QUAIDAL
|
||||
section: QB-10
|
||||
title_original_de: QB-10_Datenprüfung
|
||||
url: https://github.com/BSI-Bund/QUAIDAL/blob/main/0000_Markdown/0001_Criteria,Measurements,Metrics/0001_Qualitätsbausteine/QB-10_DataChecks.md
|
||||
commit_sha: c39b75369841b359c6bf56d6588e3768c722842f
|
||||
license_note: § 5 UrhG anwendbar; share:true im Frontmatter; Clean-Room-Ableitung.
|
||||
plagiarism_score_at_generation: 0.0204
|
||||
- id: AC-AI-DATA-QB-11-prozesse
|
||||
canonical_name: Prozesse
|
||||
description: Es ist sicherzustellen, dass jeder Schritt der Datenvorbereitung und
|
||||
-verarbeitung für KI-Trainingszwecke lückenlos protokolliert wird, um die vollständige
|
||||
Nachvollziehbarkeit der Datenherkunft und aller Transformationen zu gewährleisten.
|
||||
Diese Dokumentation muss so strukturiert sein, dass sie eine valide Reproduzierbarkeit
|
||||
der Modelle sowie eine fundierte Qualitätssicherung der zugrundeliegenden Datensätze
|
||||
ermöglicht. Durch die Erfassung aller Änderungsereignisse wird die Integrität
|
||||
der Trainingsdaten über den gesamten Lebenszyklus hinweg verifiziert.
|
||||
kind: building_block
|
||||
regulation_anchor: EU AI Act Art. 10 (Datenqualität für Hochrisiko-KI)
|
||||
related_quaidal_ids:
|
||||
- MA-18
|
||||
- MA-21
|
||||
external_refs:
|
||||
- framework: BSI Grundschutz
|
||||
citation: null
|
||||
- framework: ISO/IEC 23894
|
||||
citation: null
|
||||
- framework: ISO/IEC 42001
|
||||
citation: null
|
||||
- framework: AI Act
|
||||
citation: null
|
||||
source:
|
||||
framework: BSI QUAIDAL
|
||||
section: QB-11
|
||||
title_original_de: QB-11 Prozesse
|
||||
url: https://github.com/BSI-Bund/QUAIDAL/blob/main/0000_Markdown/0001_Criteria,Measurements,Metrics/0001_Qualitätsbausteine/QB-11_Processes.md
|
||||
commit_sha: c39b75369841b359c6bf56d6588e3768c722842f
|
||||
license_note: § 5 UrhG anwendbar; share:true im Frontmatter; Clean-Room-Ableitung.
|
||||
plagiarism_score_at_generation: 0.0
|
||||
- id: AC-AI-DATA-QB-12-merkmalsentwicklung
|
||||
canonical_name: Merkmalsentwicklung
|
||||
description: Die Erstellung und Auswahl von Eingangsmerkmalen für KI-Modelle ist
|
||||
so zu gestalten, dass sie signifikante Korrelationen zur Zielgröße aufweisen und
|
||||
redundante Informationen eliminieren. Es ist sicherzustellen, dass die transformierten
|
||||
Daten generalisierbar sind und eine hohe Informationsdichte für neue, unbekannte
|
||||
Datensätze bieten. Eine Validierung muss nachweisen, dass die abgeleiteten Merkmale
|
||||
die Interpretierbarkeit des Modells unterstützen und keine unnötige Komplexität
|
||||
verursachen.
|
||||
kind: building_block
|
||||
regulation_anchor: EU AI Act Art. 10 (Datenqualität für Hochrisiko-KI)
|
||||
related_quaidal_ids:
|
||||
- MA-01
|
||||
- MA-02
|
||||
- MA-03
|
||||
- MA-06
|
||||
- MA-12
|
||||
- MA-14
|
||||
- MA-17
|
||||
- MA-23
|
||||
- MA-24
|
||||
- MA-27
|
||||
external_refs: []
|
||||
source:
|
||||
framework: BSI QUAIDAL
|
||||
section: QB-12
|
||||
title_original_de: QB-12 Merkmalsentwicklung
|
||||
url: https://github.com/BSI-Bund/QUAIDAL/blob/main/0000_Markdown/0001_Criteria,Measurements,Metrics/0001_Qualitätsbausteine/QB-12_FeatureEngineering.md
|
||||
commit_sha: c39b75369841b359c6bf56d6588e3768c722842f
|
||||
license_note: § 5 UrhG anwendbar; share:true im Frontmatter; Clean-Room-Ableitung.
|
||||
plagiarism_score_at_generation: 0.0
|
||||
- id: AC-AI-DATA-QB-13-datenvorbereitung
|
||||
canonical_name: Datenvorbereitung
|
||||
description: Vor der Initialisierung des Trainingsprozesses sind alle Rohdaten durch
|
||||
definierte Transformationen in eine qualitätsgeprüfte und für das Modell verarbeitbare
|
||||
Struktur zu überführen. Es ist sicherzustellen, dass jede angewandte Datenaufbereitung
|
||||
die Integrität der Trainingsmenge gewährleistet und keine nicht validierten Artefakte
|
||||
in das Lernsystem einfließen. Die Durchführbarkeit dieser Schritte ist vor dem
|
||||
Start der Modellkonvergenz durch systematische Prüfverfahren nachzuweisen.
|
||||
kind: building_block
|
||||
regulation_anchor: EU AI Act Art. 10 (Datenqualität für Hochrisiko-KI)
|
||||
related_quaidal_ids:
|
||||
- MA-02
|
||||
- MA-03
|
||||
- MA-04
|
||||
- MA-13
|
||||
- MA-14
|
||||
- MA-16
|
||||
- MA-17
|
||||
- MA-23
|
||||
- MA-24
|
||||
- MA-25
|
||||
- MA-27
|
||||
- MA-29
|
||||
external_refs: []
|
||||
source:
|
||||
framework: BSI QUAIDAL
|
||||
section: QB-13
|
||||
title_original_de: QB-13 Datenvorbereitung
|
||||
url: https://github.com/BSI-Bund/QUAIDAL/blob/main/0000_Markdown/0001_Criteria,Measurements,Metrics/0001_Qualitätsbausteine/QB-13_DataPreparation.md
|
||||
commit_sha: c39b75369841b359c6bf56d6588e3768c722842f
|
||||
license_note: § 5 UrhG anwendbar; share:true im Frontmatter; Clean-Room-Ableitung.
|
||||
plagiarism_score_at_generation: 0.0
|
||||
- id: AC-AI-DATA-QB-14-expertanalysis
|
||||
canonical_name: _Expertanalysis
|
||||
description: Die Qualität der KI-Trainingsdaten ist durch eine unabhängige, manuelle
|
||||
Begutachtung durch qualifiziertes Fachpersonal zu validieren. Dabei sind mehrere
|
||||
Prüfer eigenständig einzusetzen, um subjektive Verzerrungen und Gruppenkonformitätseffekte
|
||||
bei der Bewertung auszuschließen. Die Ergebnisse dieser fachlichen Analyse müssen
|
||||
anonymisiert zusammengeführt werden, um eine objektive Beurteilung der Datensatzqualität
|
||||
zu gewährleisten.
|
||||
kind: building_block
|
||||
regulation_anchor: EU AI Act Art. 10 (Datenqualität für Hochrisiko-KI)
|
||||
related_quaidal_ids:
|
||||
- MA-06
|
||||
- MA-10
|
||||
- MA-14
|
||||
- MA-15
|
||||
- MA-21
|
||||
- MA-22
|
||||
external_refs: []
|
||||
source:
|
||||
framework: BSI QUAIDAL
|
||||
section: QB-14
|
||||
title_original_de: QB-14_Expertanalysis
|
||||
url: https://github.com/BSI-Bund/QUAIDAL/blob/main/0000_Markdown/0001_Criteria,Measurements,Metrics/0001_Qualitätsbausteine/QB-14_Expertanalysis.md
|
||||
commit_sha: c39b75369841b359c6bf56d6588e3768c722842f
|
||||
license_note: § 5 UrhG anwendbar; share:true im Frontmatter; Clean-Room-Ableitung.
|
||||
plagiarism_score_at_generation: 0.0
|
||||
- id: AC-AI-DATA-QB-15-bias-mitigation
|
||||
canonical_name: Bias-Mitigation
|
||||
description: Das System muss technische Mechanismen implementieren, um systematische
|
||||
Verzerrungen in den Trainingsdaten oder während des Lernprozesses zu identifizieren
|
||||
und zu kompensieren. Diese Maßnahmen sind unabhängig vom Entwicklungsstadium anzuwenden,
|
||||
wobei Datenanpassungen vor dem Training, Regularisierungsverfahren während des
|
||||
Lernens oder Korrekturen der Ausgabeergebnisse nach dem Training möglich sind.
|
||||
Eine Prüfung der Fairness-Kriterien ist vor der Freigabe des Modells durchzuführen,
|
||||
um sicherzustellen, dass keine diskriminierenden Muster in den Ergebnissen verbleiben.
|
||||
kind: building_block
|
||||
regulation_anchor: EU AI Act Art. 10 (Datenqualität für Hochrisiko-KI)
|
||||
related_quaidal_ids:
|
||||
- MA-30
|
||||
- QM-57
|
||||
external_refs: []
|
||||
source:
|
||||
framework: BSI QUAIDAL
|
||||
section: QB-15
|
||||
title_original_de: QB-15 Bias-Mitigation
|
||||
url: https://github.com/BSI-Bund/QUAIDAL/blob/main/0000_Markdown/0001_Criteria,Measurements,Metrics/0001_Qualitätsbausteine/QB-15_Bias-Mitigation.md
|
||||
commit_sha: c39b75369841b359c6bf56d6588e3768c722842f
|
||||
license_note: § 5 UrhG anwendbar; share:true im Frontmatter; Clean-Room-Ableitung.
|
||||
plagiarism_score_at_generation: 0.0
|
||||
@@ -0,0 +1,280 @@
|
||||
source: Derived from BSI QUAIDAL (Clean-Room)
|
||||
source_url: https://github.com/BSI-Bund/QUAIDAL
|
||||
commit_sha: c39b75369841b359c6bf56d6588e3768c722842f
|
||||
plagiarism_limit_4gram: 0.2
|
||||
generated_by_model: qwen3.5:35b-a3b
|
||||
controls:
|
||||
- id: MC-AI-DATA-QKB-01-repraesentativitaet
|
||||
canonical_name: Repräsentativität
|
||||
description: Der Trainingsdatensatz muss die statistische Verteilung der Zielpopulation
|
||||
exakt abbilden, um systematische Verzerrungen im Modell zu vermeiden. Es ist sicherzustellen,
|
||||
dass alle relevanten Merkmalsausprägungen in ausreichender Häufigkeit und ohne
|
||||
Über- oder Unterrepräsentation vorliegen. Die Datenmenge ist so zu dimensionieren,
|
||||
dass eine robuste Generalisierungsfähigkeit für alle Subgruppen der Gesamtpopulation
|
||||
gewährleistet wird. Eine Prüfung auf Stichprobenqualität ist vor dem Training
|
||||
durchzuführen.
|
||||
kind: criterion
|
||||
regulation_anchor: EU AI Act Art. 10 (Datenqualität für Hochrisiko-KI)
|
||||
related_quaidal_ids:
|
||||
- QB-03
|
||||
- QB-04
|
||||
- QB-05
|
||||
- QB-06
|
||||
- QB-15
|
||||
external_refs:
|
||||
- framework: AI Act
|
||||
citation: Artikel 10
|
||||
- framework: ISO/IEC 25012
|
||||
citation: null
|
||||
source:
|
||||
framework: BSI QUAIDAL
|
||||
section: QKB-01
|
||||
title_original_de: QKB-01 Repräsentativität
|
||||
url: https://github.com/BSI-Bund/QUAIDAL/blob/main/0000_Markdown/0001_Criteria,Measurements,Metrics/0000_Qualitätskriterien/QKB-01_Representativity.md
|
||||
commit_sha: c39b75369841b359c6bf56d6588e3768c722842f
|
||||
license_note: § 5 UrhG anwendbar; share:true im Frontmatter; Clean-Room-Ableitung.
|
||||
plagiarism_score_at_generation: 0.0
|
||||
- id: MC-AI-DATA-QKB-02-vollstaendigkeit
|
||||
canonical_name: Vollständigkeit
|
||||
description: Der Datensatz muss sämtliche für das spezifische KI-Modell erwarteten
|
||||
Attribute und Merkmalsausprägungen lückenlos beinhalten. Es ist sicherzustellen,
|
||||
dass keine Entitätsinstanzen fehlen und alle definierten Merkmale mit Werten belegt
|
||||
sind. Eine Prüfung auf fehlende Werte oder unvollständige Attributmengen ist vor
|
||||
dem Training zwingend durchzuführen, um Verzerrungen zu vermeiden.
|
||||
kind: criterion
|
||||
regulation_anchor: EU AI Act Art. 10 (Datenqualität für Hochrisiko-KI)
|
||||
related_quaidal_ids:
|
||||
- QB-07
|
||||
- QB-09
|
||||
external_refs:
|
||||
- framework: AI Act
|
||||
citation: Artikel 10
|
||||
- framework: BSI AIC4
|
||||
citation: null
|
||||
- framework: ISO/IEC 25012
|
||||
citation: null
|
||||
- framework: ISO/IEC 25024
|
||||
citation: null
|
||||
source:
|
||||
framework: BSI QUAIDAL
|
||||
section: QKB-02
|
||||
title_original_de: QKB-02 Vollständigkeit
|
||||
url: https://github.com/BSI-Bund/QUAIDAL/blob/main/0000_Markdown/0001_Criteria,Measurements,Metrics/0000_Qualitätskriterien/QKB-02_Completeness.md
|
||||
commit_sha: c39b75369841b359c6bf56d6588e3768c722842f
|
||||
license_note: § 5 UrhG anwendbar; share:true im Frontmatter; Clean-Room-Ableitung.
|
||||
plagiarism_score_at_generation: 0.0
|
||||
- id: MC-AI-DATA-QKB-03-genauigkeit
|
||||
canonical_name: Genauigkeit
|
||||
description: Die Integrität der KI-Trainingsdaten erfordert, dass jeder einzelne
|
||||
Datenelementwert eine definierte numerische oder symbolische Übereinstimmung mit
|
||||
dem referenzierten Sollwert aufweist. Es ist sicherzustellen, dass Abweichungen
|
||||
innerhalb festgelegter Toleranzgrenzen bezüglich Rundung, Formatierung und Messauflösung
|
||||
bleiben. Die Einhaltung dieser Spezifikation ist durch automatisierte Prüfverfahren
|
||||
vor jedem Trainingslauf zu verifizieren.
|
||||
kind: criterion
|
||||
regulation_anchor: EU AI Act Art. 10 (Datenqualität für Hochrisiko-KI)
|
||||
related_quaidal_ids:
|
||||
- QB-01
|
||||
- QB-02
|
||||
external_refs:
|
||||
- framework: ISO/IEC 25012
|
||||
citation: null
|
||||
source:
|
||||
framework: BSI QUAIDAL
|
||||
section: QKB-03
|
||||
title_original_de: QKB-03 Genauigkeit
|
||||
url: https://github.com/BSI-Bund/QUAIDAL/blob/main/0000_Markdown/0001_Criteria,Measurements,Metrics/0000_Qualitätskriterien/QKB-03_Accuracy.md
|
||||
commit_sha: c39b75369841b359c6bf56d6588e3768c722842f
|
||||
license_note: § 5 UrhG anwendbar; share:true im Frontmatter; Clean-Room-Ableitung.
|
||||
plagiarism_score_at_generation: 0.0
|
||||
- id: MC-AI-DATA-QKB-04-konsistenz
|
||||
canonical_name: Konsistenz
|
||||
description: Das System muss sicherstellen, dass alle Eingabedaten für das KI-Training
|
||||
logisch kohärent und frei von internen Widersprüchen sind. Einheitliche Kodierungen
|
||||
für Kategorien sowie konsistente Formatierungen sind zwingend erforderlich, um
|
||||
eine fehlerfreie Generalisierung durch das Modell zu ermöglichen. Jede Abweichung
|
||||
von den definierten Datenstandards ist durch automatische Prüfmechanismen zu identifizieren
|
||||
und zu unterbinden.
|
||||
kind: criterion
|
||||
regulation_anchor: EU AI Act Art. 10 (Datenqualität für Hochrisiko-KI)
|
||||
related_quaidal_ids:
|
||||
- QB-02
|
||||
- QB-07
|
||||
- QB-08
|
||||
- QB-10
|
||||
- QB-11
|
||||
- QB-12
|
||||
external_refs:
|
||||
- framework: ISO/IEC 25012
|
||||
citation: null
|
||||
source:
|
||||
framework: BSI QUAIDAL
|
||||
section: QKB-04
|
||||
title_original_de: QKB-04 Konsistenz
|
||||
url: https://github.com/BSI-Bund/QUAIDAL/blob/main/0000_Markdown/0001_Criteria,Measurements,Metrics/0000_Qualitätskriterien/QKB-04_Consistency.md
|
||||
commit_sha: c39b75369841b359c6bf56d6588e3768c722842f
|
||||
license_note: § 5 UrhG anwendbar; share:true im Frontmatter; Clean-Room-Ableitung.
|
||||
plagiarism_score_at_generation: 0.0
|
||||
- id: MC-AI-DATA-QKB-05-korrektheit
|
||||
canonical_name: Korrektheit
|
||||
description: Das KI-Modell muss ausschließlich auf Datensätzen trainiert werden,
|
||||
die inhaltlich frei von Fehlern sind und den tatsächlichen Gegebenheiten oder
|
||||
definierten Referenzstandards exakt entsprechen. Es ist sicherzustellen, dass
|
||||
jede annotierte Information den als wahr geltenden Zustand im Anwendungskontext
|
||||
fehlerfrei abbildet. Die Validierung der Trainingsdaten ist vor Beginn des Lernprozesses
|
||||
durchzuführen, um sicherzustellen, dass keine inkorrekten Werte die Modellleistung
|
||||
beeinträchtigen.
|
||||
kind: criterion
|
||||
regulation_anchor: EU AI Act Art. 10 (Datenqualität für Hochrisiko-KI)
|
||||
related_quaidal_ids:
|
||||
- QB-09
|
||||
- QB-10
|
||||
- QB-12
|
||||
- QB-14
|
||||
external_refs:
|
||||
- framework: ISO/IEC 25012
|
||||
citation: null
|
||||
- framework: BSI AIC4
|
||||
citation: null
|
||||
- framework: AI Act
|
||||
citation: Artikel 10
|
||||
source:
|
||||
framework: BSI QUAIDAL
|
||||
section: QKB-05
|
||||
title_original_de: QKB-05 Korrektheit
|
||||
url: https://github.com/BSI-Bund/QUAIDAL/blob/main/0000_Markdown/0001_Criteria,Measurements,Metrics/0000_Qualitätskriterien/QKB-05_Correctness.md
|
||||
commit_sha: c39b75369841b359c6bf56d6588e3768c722842f
|
||||
license_note: § 5 UrhG anwendbar; share:true im Frontmatter; Clean-Room-Ableitung.
|
||||
plagiarism_score_at_generation: 0.0
|
||||
- id: MC-AI-DATA-QKB-06-einheitlichkeit
|
||||
canonical_name: Einheitlichkeit
|
||||
description: Die Konsistenz der KI-Trainingsdaten ist durch die strikte Einhaltung
|
||||
definierter Syntaxregeln und Datenstrukturen sicherzustellen. Jedes Datenelement
|
||||
muss vor der Verarbeitung gemäß festgelegten Standards formatiert werden, um strukturelle
|
||||
Abweichungen auszuschließen. Eine Prüfung der formalen Einheitlichkeit ist unabhängig
|
||||
von der inhaltlichen Richtigkeit der Werte durchzuführen.
|
||||
kind: criterion
|
||||
regulation_anchor: EU AI Act Art. 10 (Datenqualität für Hochrisiko-KI)
|
||||
related_quaidal_ids:
|
||||
- QB-02
|
||||
- QB-08
|
||||
- QB-10
|
||||
- QB-12
|
||||
- QB-14
|
||||
external_refs:
|
||||
- framework: ISO/IEC 25012
|
||||
citation: null
|
||||
source:
|
||||
framework: BSI QUAIDAL
|
||||
section: QKB-06
|
||||
title_original_de: QKB-06 Einheitlichkeit
|
||||
url: https://github.com/BSI-Bund/QUAIDAL/blob/main/0000_Markdown/0001_Criteria,Measurements,Metrics/0000_Qualitätskriterien/QKB-06_Uniformity.md
|
||||
commit_sha: c39b75369841b359c6bf56d6588e3768c722842f
|
||||
license_note: § 5 UrhG anwendbar; share:true im Frontmatter; Clean-Room-Ableitung.
|
||||
plagiarism_score_at_generation: 0.0
|
||||
- id: MC-AI-DATA-QKB-07-gueltigkeit
|
||||
canonical_name: Gültigkeit
|
||||
description: Das System muss sicherstellen, dass die für das KI-Training verwendeten
|
||||
Daten inhaltlich exakt das intendierte Zielkonstrukt abbilden und nicht nur oberflächliche
|
||||
Korrelationen erfassen. Es ist zu prüfen, ob die erfassten Merkmale den theoretischen
|
||||
Anforderungen an den Messgegenstand entsprechen, um eine valide Grundlage für
|
||||
Ableitungen zu gewährleisten. Eine Abweichung zwischen dem gemessenen Inhalt und
|
||||
dem definierten Zielkonzept ist als Fehlerzustand zu klassifizieren und muss ausgeschlossen
|
||||
werden.
|
||||
kind: criterion
|
||||
regulation_anchor: EU AI Act Art. 10 (Datenqualität für Hochrisiko-KI)
|
||||
related_quaidal_ids:
|
||||
- QB-02
|
||||
- QB-05
|
||||
- QB-09
|
||||
- QB-10
|
||||
- QB-14
|
||||
external_refs:
|
||||
- framework: ISO/IEC 25012
|
||||
citation: null
|
||||
source:
|
||||
framework: BSI QUAIDAL
|
||||
section: QKB-07
|
||||
title_original_de: QKB-07 Gültigkeit
|
||||
url: https://github.com/BSI-Bund/QUAIDAL/blob/main/0000_Markdown/0001_Criteria,Measurements,Metrics/0000_Qualitätskriterien/QKB-07_Validity.md
|
||||
commit_sha: c39b75369841b359c6bf56d6588e3768c722842f
|
||||
license_note: § 5 UrhG anwendbar; share:true im Frontmatter; Clean-Room-Ableitung.
|
||||
plagiarism_score_at_generation: 0.0
|
||||
- id: MC-AI-DATA-QKB-08-eindeutigkeit
|
||||
canonical_name: Eindeutigkeit
|
||||
description: Jeder Datensatz im Trainingskorpus muss eine eindeutige Identität besitzen,
|
||||
um die Entstehung redundanter Instanzen auszuschließen. Es ist sicherzustellen,
|
||||
dass keine doppelten oder mehrdeutigen Einträge vorliegen, da diese die Modellgeneralisierung
|
||||
beeinträchtigen und zu Overfitting führen können. Die Validierung muss nachweisen,
|
||||
dass jede Dateneinheit eindeutig identifizierbar ist und logisch von anderen unterscheidbar
|
||||
bleibt.
|
||||
kind: criterion
|
||||
regulation_anchor: EU AI Act Art. 10 (Datenqualität für Hochrisiko-KI)
|
||||
related_quaidal_ids:
|
||||
- QB-05
|
||||
- QB-10
|
||||
- QB-13
|
||||
external_refs:
|
||||
- framework: ISO/IEC 25012
|
||||
citation: null
|
||||
source:
|
||||
framework: BSI QUAIDAL
|
||||
section: QKB-08
|
||||
title_original_de: QKB-08 Eindeutigkeit
|
||||
url: https://github.com/BSI-Bund/QUAIDAL/blob/main/0000_Markdown/0001_Criteria,Measurements,Metrics/0000_Qualitätskriterien/QKB-08_Uniqueness.md
|
||||
commit_sha: c39b75369841b359c6bf56d6588e3768c722842f
|
||||
license_note: § 5 UrhG anwendbar; share:true im Frontmatter; Clean-Room-Ableitung.
|
||||
plagiarism_score_at_generation: 0.0
|
||||
- id: MC-AI-DATA-QKB-09-sichere-quellen
|
||||
canonical_name: Sichere Quellen
|
||||
description: Für KI-Trainingsdaten muss eine lückenlose Provenienz-Dokumentation
|
||||
etabliert werden, die jeden Verarbeitungsschritt von der Erfassung bis zur finalen
|
||||
Nutzung nachvollziehbar macht. Es ist sicherzustellen, dass alle Transformationen
|
||||
und Herkunftsinformationen vollständig erfasst sind, um die Datenintegrität und
|
||||
-qualität kontinuierlich verifizieren zu können. Die Nachprüfbarkeit dieser Metadaten
|
||||
ist zwingend erforderlich, um potenzielle Qualitätsmängel oder Manipulationen
|
||||
in den Trainingsbeständen frühzeitig zu identifizieren.
|
||||
kind: criterion
|
||||
regulation_anchor: EU AI Act Art. 10 (Datenqualität für Hochrisiko-KI)
|
||||
related_quaidal_ids:
|
||||
- QB-09
|
||||
- QB-11
|
||||
external_refs:
|
||||
- framework: ISO/IEC 25012
|
||||
citation: null
|
||||
- framework: BSI AIC4
|
||||
citation: null
|
||||
source:
|
||||
framework: BSI QUAIDAL
|
||||
section: QKB-09
|
||||
title_original_de: QKB-09 Sichere Quellen
|
||||
url: https://github.com/BSI-Bund/QUAIDAL/blob/main/0000_Markdown/0001_Criteria,Measurements,Metrics/0000_Qualitätskriterien/QKB-09_SecureSource.md
|
||||
commit_sha: c39b75369841b359c6bf56d6588e3768c722842f
|
||||
license_note: § 5 UrhG anwendbar; share:true im Frontmatter; Clean-Room-Ableitung.
|
||||
plagiarism_score_at_generation: 0.0
|
||||
- id: MC-AI-DATA-QKB-10-daten-mit-personenbezug
|
||||
canonical_name: Daten mit Personenbezug
|
||||
description: Das System muss vor der Nutzung von Trainingsdaten eine automatisierte
|
||||
Prüfung durchführen, um personenbezogene Informationen zu identifizieren. Ist
|
||||
derartige Datenbestandteil der Eingabedaten, ist deren vollständige und nachweisbare
|
||||
Entfernung sicherzustellen, bevor ein Modelltraining initiiert wird. Die Integrität
|
||||
der verbleibenden Datensätze ist durch technische Maßnahmen gegen unbeabsichtigte
|
||||
Wiederverwendung zu gewährleisten.
|
||||
kind: criterion
|
||||
regulation_anchor: EU AI Act Art. 10 (Datenqualität für Hochrisiko-KI)
|
||||
related_quaidal_ids:
|
||||
- QB-09
|
||||
- QB-10
|
||||
- QB-11
|
||||
- QB-14
|
||||
external_refs:
|
||||
- framework: EU GDPR
|
||||
citation: null
|
||||
source:
|
||||
framework: BSI QUAIDAL
|
||||
section: QKB-10
|
||||
title_original_de: QKB-10 Daten mit Personenbezug
|
||||
url: https://github.com/BSI-Bund/QUAIDAL/blob/main/0000_Markdown/0001_Criteria,Measurements,Metrics/0000_Qualitätskriterien/QKB-10_PersonalDataCheck.md
|
||||
commit_sha: c39b75369841b359c6bf56d6588e3768c722842f
|
||||
license_note: § 5 UrhG anwendbar; share:true im Frontmatter; Clean-Room-Ableitung.
|
||||
plagiarism_score_at_generation: 0.0
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,753 @@
|
||||
source: Derived from BSI QUAIDAL (Clean-Room)
|
||||
source_url: https://github.com/BSI-Bund/QUAIDAL
|
||||
commit_sha: c39b75369841b359c6bf56d6588e3768c722842f
|
||||
plagiarism_limit_4gram: 0.2
|
||||
generated_by_model: qwen3.5:35b-a3b
|
||||
controls:
|
||||
- id: MIT-AI-DATA-MA-01-datentyp-validierung
|
||||
canonical_name: Datentyp Validierung
|
||||
description: Es ist sicherzustellen, dass alle Eingabedaten und Trainingsdatensätze
|
||||
vor der Verarbeitung auf Konformität mit den definierten Schemata und Datentypen
|
||||
des Modells geprüft werden. Abweichungen von den erwarteten Formaten sind automatisch
|
||||
zu identifizieren und müssen entweder bereinigt oder ausgeschlossen werden, um
|
||||
Inferenzfehler zu verhindern. Diese Validierung ist als automatisierter Schritt
|
||||
in den Datenpipelines zu implementieren, um die Integrität der KI-Systeme zu gewährleisten.
|
||||
kind: measure
|
||||
regulation_anchor: EU AI Act Art. 10 (Datenqualität für Hochrisiko-KI)
|
||||
related_quaidal_ids:
|
||||
- QM-32
|
||||
- QM-34
|
||||
external_refs: []
|
||||
source:
|
||||
framework: BSI QUAIDAL
|
||||
section: MA-01
|
||||
title_original_de: MA-01 Datentyp Validierung
|
||||
url: https://github.com/BSI-Bund/QUAIDAL/blob/main/0000_Markdown/0001_Criteria,Measurements,Metrics/0002_Maßnahmen/MA-01_Datatype%20Validation.md
|
||||
commit_sha: c39b75369841b359c6bf56d6588e3768c722842f
|
||||
license_note: § 5 UrhG anwendbar; share:true im Frontmatter; Clean-Room-Ableitung.
|
||||
plagiarism_score_at_generation: 0.0
|
||||
- id: MIT-AI-DATA-MA-02-format-pruefung
|
||||
canonical_name: Format Prüfung
|
||||
description: Die Eingabedaten für KI-Trainingszwecke sind vor der Verarbeitung auf
|
||||
strukturelle Korrektheit zu validieren, wobei Datentypen wie Zeitstempel oder
|
||||
Textfelder exakt den definierten Schemata entsprechen müssen. Durch die erzwingung
|
||||
einer einheitlichen Formatierung wird verhindert, dass regionale Abweichungen
|
||||
oder inkonsistente Darstellungen zu Fehlinterpretationen im Modell führen. Die
|
||||
Konformität ist automatisiert zu prüfen, um sicherzustellen, dass keine nicht
|
||||
konformen Datensätze in den Lernprozess eingehen.
|
||||
kind: measure
|
||||
regulation_anchor: EU AI Act Art. 10 (Datenqualität für Hochrisiko-KI)
|
||||
related_quaidal_ids:
|
||||
- QM-32
|
||||
- QM-34
|
||||
- QM-43
|
||||
external_refs: []
|
||||
source:
|
||||
framework: BSI QUAIDAL
|
||||
section: MA-02
|
||||
title_original_de: MA-02 Format Prüfung
|
||||
url: https://github.com/BSI-Bund/QUAIDAL/blob/main/0000_Markdown/0001_Criteria,Measurements,Metrics/0002_Maßnahmen/MA-02_Format%20Check.md
|
||||
commit_sha: c39b75369841b359c6bf56d6588e3768c722842f
|
||||
license_note: § 5 UrhG anwendbar; share:true im Frontmatter; Clean-Room-Ableitung.
|
||||
plagiarism_score_at_generation: 0.0
|
||||
- id: MIT-AI-DATA-MA-03-bereichspruefung
|
||||
canonical_name: Bereichsprüfung
|
||||
description: Das System muss vor dem KI-Training eine automatische Validierung aller
|
||||
Eingangsmerkmale durchführen, um Werte außerhalb definierter physikalischer oder
|
||||
logischer Grenzen zu identifizieren. Dabei sind insbesondere inkonsistente Datentypen,
|
||||
fehlerhafte Maßeinheiten und statistisch unplausible Ausreißer zu detektieren
|
||||
und zu isolieren. Die Integrität des Trainingsdatensatzes ist erst dann gewährleistet,
|
||||
wenn alle nicht konformen Einträge ausgeschlossen oder korrigiert wurden, bevor
|
||||
der Lernprozess initiiert wird.
|
||||
kind: measure
|
||||
regulation_anchor: EU AI Act Art. 10 (Datenqualität für Hochrisiko-KI)
|
||||
related_quaidal_ids:
|
||||
- QM-51
|
||||
- QM-52
|
||||
external_refs: []
|
||||
source:
|
||||
framework: BSI QUAIDAL
|
||||
section: MA-03
|
||||
title_original_de: MA-03 Bereichsprüfung
|
||||
url: https://github.com/BSI-Bund/QUAIDAL/blob/main/0000_Markdown/0001_Criteria,Measurements,Metrics/0002_Maßnahmen/MA-03_Range%20Check.md
|
||||
commit_sha: c39b75369841b359c6bf56d6588e3768c722842f
|
||||
license_note: § 5 UrhG anwendbar; share:true im Frontmatter; Clean-Room-Ableitung.
|
||||
plagiarism_score_at_generation: 0.0
|
||||
- id: MIT-AI-DATA-MA-04-over-undersampling
|
||||
canonical_name: Over-Undersampling
|
||||
description: Das Daten-Set für das KI-Training ist auf ein ausgewogenes Klassenverhältnis
|
||||
zu prüfen, wobei eine künstliche Aufstockung seltener Kategorien durch synthetische
|
||||
Generierung oder Duplizierung zulässig ist. Alternativ ist eine Reduktion der
|
||||
Datenpunkte der Mehrheitsklasse nach definierten Kriterien durchzuführen, um eine
|
||||
Verzerrung des Modells zu vermeiden. Die angewandte Methode zur Erreichung dieses
|
||||
Gleichgewichts ist dokumentiert und muss reproduzierbar sein.
|
||||
kind: measure
|
||||
regulation_anchor: EU AI Act Art. 10 (Datenqualität für Hochrisiko-KI)
|
||||
related_quaidal_ids:
|
||||
- QM-34
|
||||
- QM-38
|
||||
- QM-57
|
||||
external_refs: []
|
||||
source:
|
||||
framework: BSI QUAIDAL
|
||||
section: MA-04
|
||||
title_original_de: MA-04 Over-Undersampling
|
||||
url: https://github.com/BSI-Bund/QUAIDAL/blob/main/0000_Markdown/0001_Criteria,Measurements,Metrics/0002_Maßnahmen/MA-04_Over-Undersampling.md
|
||||
commit_sha: c39b75369841b359c6bf56d6588e3768c722842f
|
||||
license_note: § 5 UrhG anwendbar; share:true im Frontmatter; Clean-Room-Ableitung.
|
||||
plagiarism_score_at_generation: 0.0
|
||||
- id: MIT-AI-DATA-MA-05-automatisierte-aufgaben
|
||||
canonical_name: Automatisierte Aufgaben
|
||||
description: Wiederkehrende Prozesse der Datenvorverarbeitung und Qualitätsprüfung
|
||||
im KI-Lebenszyklus sind durch automatisierte Mechanismen zu implementieren. Die
|
||||
Ausführung dieser Aufgaben muss so konfiguriert sein, dass eine konsistente Ergebnisqualität
|
||||
über alle Durchläufe hinweg sichergestellt wird. Es ist zu prüfen, dass die eingesetzten
|
||||
Automatisierungswerkzeuge spezifische Validierungsregeln für Trainingsdaten zuverlässig
|
||||
anwenden.
|
||||
kind: measure
|
||||
regulation_anchor: EU AI Act Art. 10 (Datenqualität für Hochrisiko-KI)
|
||||
related_quaidal_ids:
|
||||
- MA-02
|
||||
- MA-03
|
||||
- QM-10
|
||||
- QM-34
|
||||
- QM-64
|
||||
external_refs:
|
||||
- framework: AI Act
|
||||
citation: null
|
||||
source:
|
||||
framework: BSI QUAIDAL
|
||||
section: MA-05
|
||||
title_original_de: MA-05 Automatisierte Aufgaben
|
||||
url: https://github.com/BSI-Bund/QUAIDAL/blob/main/0000_Markdown/0001_Criteria,Measurements,Metrics/0002_Maßnahmen/MA-05_Automated%20Tasks.md
|
||||
commit_sha: c39b75369841b359c6bf56d6588e3768c722842f
|
||||
license_note: § 5 UrhG anwendbar; share:true im Frontmatter; Clean-Room-Ableitung.
|
||||
plagiarism_score_at_generation: 0.0
|
||||
- id: MIT-AI-DATA-MA-06-experten-auswertung
|
||||
canonical_name: Experten Auswertung
|
||||
description: Für die Validierung von KI-Trainingsdaten ist eine manuelle Prüfung
|
||||
durch qualifizierte Fachexperten zwingend erforderlich. Diese Experten müssen
|
||||
die inhaltliche Gültigkeit, Relevanz und Korrektheit der Datensätze auf Basis
|
||||
domänenspezifischen Wissens systematisch evaluieren. Das Ergebnis dieser Begutachtung
|
||||
dient dazu, methodische Fehler oder qualitative Mängel frühzeitig zu identifizieren
|
||||
und konkrete Maßnahmen zur Datenbereinigung abzuleiten.
|
||||
kind: measure
|
||||
regulation_anchor: EU AI Act Art. 10 (Datenqualität für Hochrisiko-KI)
|
||||
related_quaidal_ids:
|
||||
- QM-16
|
||||
- QM-30
|
||||
- QM-43
|
||||
- QM-45
|
||||
- QM-59
|
||||
- QM-70
|
||||
external_refs:
|
||||
- framework: ISO/IEC 25012
|
||||
citation: null
|
||||
- framework: ISO/IEC 25024
|
||||
citation: null
|
||||
source:
|
||||
framework: BSI QUAIDAL
|
||||
section: MA-06
|
||||
title_original_de: MA-06 Experten Auswertung
|
||||
url: https://github.com/BSI-Bund/QUAIDAL/blob/main/0000_Markdown/0001_Criteria,Measurements,Metrics/0002_Maßnahmen/MA-06_Expert%20Evaluation.md
|
||||
commit_sha: c39b75369841b359c6bf56d6588e3768c722842f
|
||||
license_note: § 5 UrhG anwendbar; share:true im Frontmatter; Clean-Room-Ableitung.
|
||||
plagiarism_score_at_generation: 0.0204
|
||||
- id: MIT-AI-DATA-MA-07-massenbeteiligung
|
||||
canonical_name: Massenbeteiligung
|
||||
description: Das System muss Mechanismen implementieren, um die Qualität von Trainingsdaten
|
||||
durch dezentrale Validierung durch eine heterogene Gruppe externer Prüfer sicherzustellen.
|
||||
Es ist zwingend erforderlich, dass die Ergebnisse dieser kollektiven Überprüfung
|
||||
mit internen Qualitätsstandards abgeglichen werden, um systematische Fehler in
|
||||
den annotierten Datensätzen zu identifizieren. Die Integrität der KI-Modelle ist
|
||||
nur gewährleistet, wenn diese skalierbare Prüfprozedur für kritische Datenmengen
|
||||
routinemäßig angewendet wird.
|
||||
kind: measure
|
||||
regulation_anchor: EU AI Act Art. 10 (Datenqualität für Hochrisiko-KI)
|
||||
related_quaidal_ids:
|
||||
- MA-06
|
||||
- QM-03
|
||||
- QM-16
|
||||
- QM-43
|
||||
external_refs: []
|
||||
source:
|
||||
framework: BSI QUAIDAL
|
||||
section: MA-07
|
||||
title_original_de: MA-07 Massenbeteiligung
|
||||
url: https://github.com/BSI-Bund/QUAIDAL/blob/main/0000_Markdown/0001_Criteria,Measurements,Metrics/0002_Maßnahmen/MA-07_Crowdsourcing.md
|
||||
commit_sha: c39b75369841b359c6bf56d6588e3768c722842f
|
||||
license_note: § 5 UrhG anwendbar; share:true im Frontmatter; Clean-Room-Ableitung.
|
||||
plagiarism_score_at_generation: 0.0
|
||||
- id: MIT-AI-DATA-MA-08-verteilungsanalyse
|
||||
canonical_name: Verteilungsanalyse
|
||||
description: Es ist sicherzustellen, dass die Verteilung der Trainingsdaten über
|
||||
alle relevanten Klassen und Merkmalsbereiche systematisch auf statistische Verzerrungen
|
||||
und Anomalien geprüft wird. Diese Analyse muss nachweisen, dass das Modell auf
|
||||
einer repräsentativen und ausgewogenen Datenbasis trainiert wurde, um die Generalisierungsfähigkeit
|
||||
der Vorhersagen zu gewährleisten. Die Ergebnisse der Verteilungsprüfung sind vor
|
||||
Beginn des Trainings zu dokumentieren und bei signifikanten Abweichungen sind
|
||||
Korrekturmaßnahmen einzuleiten.
|
||||
kind: measure
|
||||
regulation_anchor: EU AI Act Art. 10 (Datenqualität für Hochrisiko-KI)
|
||||
related_quaidal_ids:
|
||||
- MA-06
|
||||
- QM-10
|
||||
- QM-11
|
||||
- QM-51
|
||||
external_refs: []
|
||||
source:
|
||||
framework: BSI QUAIDAL
|
||||
section: MA-08
|
||||
title_original_de: MA-08 Verteilungsanalyse
|
||||
url: https://github.com/BSI-Bund/QUAIDAL/blob/main/0000_Markdown/0001_Criteria,Measurements,Metrics/0002_Maßnahmen/MA-08_DistributionAnalysis.md
|
||||
commit_sha: c39b75369841b359c6bf56d6588e3768c722842f
|
||||
license_note: § 5 UrhG anwendbar; share:true im Frontmatter; Clean-Room-Ableitung.
|
||||
plagiarism_score_at_generation: 0.0339
|
||||
- id: MIT-AI-DATA-MA-09-vergleichgrundgesamtheit
|
||||
canonical_name: VergleichGrundgesamtheit
|
||||
description: Das System muss eine repräsentative Referenzstichprobe aus der Zielverteilung
|
||||
bereitstellen, um die Validität von KI-Trainingsdaten zu verifizieren. Es ist
|
||||
sicherzustellen, dass diese Referenzdaten als Goldstandard dienen, um Abweichungen
|
||||
zwischen dem Trainingsset und der tatsächlichen Grundgesamtheit zu quantifizieren.
|
||||
Die Übereinstimmung ist durch einen automatisierten Abgleich mit den vorab definierten
|
||||
Verteilungsparametern zu prüfen.
|
||||
kind: measure
|
||||
regulation_anchor: EU AI Act Art. 10 (Datenqualität für Hochrisiko-KI)
|
||||
related_quaidal_ids:
|
||||
- MA-9
|
||||
- QM-51
|
||||
- QM-52
|
||||
external_refs: []
|
||||
source:
|
||||
framework: BSI QUAIDAL
|
||||
section: MA-09
|
||||
title_original_de: MA-09 VergleichGrundgesamtheit
|
||||
url: https://github.com/BSI-Bund/QUAIDAL/blob/main/0000_Markdown/0001_Criteria,Measurements,Metrics/0002_Maßnahmen/MA-09_CompareGroundtruth.md
|
||||
commit_sha: c39b75369841b359c6bf56d6588e3768c722842f
|
||||
license_note: § 5 UrhG anwendbar; share:true im Frontmatter; Clean-Room-Ableitung.
|
||||
plagiarism_score_at_generation: 0.0
|
||||
- id: MIT-AI-DATA-MA-10-gewichtung-der-daten
|
||||
canonical_name: Gewichtung der Daten
|
||||
description: Für KI-Trainingsdatensätze ist eine manuelle Gewichtung der einzelnen
|
||||
Merkmale zwingend erforderlich, um systematische Verzerrungen zu minimieren. Diese
|
||||
Maßnahme dient der Sicherstellung einer ausgewogenen Datenrepräsentation und verbessert
|
||||
die Generalisierungsfähigkeit des Modells auf spezifische Anwendungsfälle. Die
|
||||
Zuordnung der Gewichtungsfaktoren ist vor dem Training durchzuführen und muss
|
||||
dokumentiert werden, um die Nachvollziehbarkeit der Datenqualität zu gewährleisten.
|
||||
kind: measure
|
||||
regulation_anchor: EU AI Act Art. 10 (Datenqualität für Hochrisiko-KI)
|
||||
related_quaidal_ids:
|
||||
- QM-10
|
||||
- QM-18
|
||||
- QM-28
|
||||
- QM-29
|
||||
- QM-37
|
||||
- QM-38
|
||||
- QM-39
|
||||
external_refs: []
|
||||
source:
|
||||
framework: BSI QUAIDAL
|
||||
section: MA-10
|
||||
title_original_de: MA-10 Gewichtung der Daten
|
||||
url: https://github.com/BSI-Bund/QUAIDAL/blob/main/0000_Markdown/0001_Criteria,Measurements,Metrics/0002_Maßnahmen/MA-10_ManualWeights.md
|
||||
commit_sha: c39b75369841b359c6bf56d6588e3768c722842f
|
||||
license_note: § 5 UrhG anwendbar; share:true im Frontmatter; Clean-Room-Ableitung.
|
||||
plagiarism_score_at_generation: 0.0
|
||||
- id: MIT-AI-DATA-MA-11-stichprobengroesse
|
||||
canonical_name: Stichprobengröße
|
||||
description: Die Menge der für das Training verwendeten Daten ist so zu dimensionieren,
|
||||
dass statistisch signifikante Ergebnisse bei definiertem Konfidenzniveau und akzeptabler
|
||||
Fehlervarianz gewährleistet sind. Die Datengröße muss iterativ angepasst werden,
|
||||
wobei sowohl die Gesamtgröße der zugrundeliegenden Population als auch die spezifische
|
||||
Art der Datenerweiterung systematisch zu berücksichtigen sind. Eine Validierung
|
||||
der Datenqualität ist zwingend erforderlich, um Verzerrungen durch unterschiedliche
|
||||
Skalierungsmethoden auszuschließen.
|
||||
kind: measure
|
||||
regulation_anchor: EU AI Act Art. 10 (Datenqualität für Hochrisiko-KI)
|
||||
related_quaidal_ids:
|
||||
- QM-08
|
||||
- QM-09
|
||||
- QM-39
|
||||
- QM-41
|
||||
external_refs: []
|
||||
source:
|
||||
framework: BSI QUAIDAL
|
||||
section: MA-11
|
||||
title_original_de: MA-11 Stichprobengröße
|
||||
url: https://github.com/BSI-Bund/QUAIDAL/blob/main/0000_Markdown/0001_Criteria,Measurements,Metrics/0002_Maßnahmen/MA-11_Trainingsdataset%20Size.md
|
||||
commit_sha: c39b75369841b359c6bf56d6588e3768c722842f
|
||||
license_note: § 5 UrhG anwendbar; share:true im Frontmatter; Clean-Room-Ableitung.
|
||||
plagiarism_score_at_generation: 0.0
|
||||
- id: MIT-AI-DATA-MA-12-abdeckung-relevanter-merkmale
|
||||
canonical_name: Abdeckung relevanter Merkmale
|
||||
description: Das Trainingsdatenset muss vollständig alle für die spezifische Problemstellung
|
||||
essenziellen Eingangsvariablen enthalten, um eine lückenlose Merkmalsabdeckung
|
||||
zu gewährleisten. Es ist sicherzustellen, dass keine kritischen Einflussgrößen
|
||||
fehlen, da sonst das Modell keine verlässlichen Korrelationen erlernen kann. Die
|
||||
Vollständigkeit des Merkmalsraums ist vor Beginn des Trainingsprozesses durch
|
||||
eine formale Prüfung zu verifizieren.
|
||||
kind: measure
|
||||
regulation_anchor: EU AI Act Art. 10 (Datenqualität für Hochrisiko-KI)
|
||||
related_quaidal_ids:
|
||||
- MA-06
|
||||
- MA-14
|
||||
- QM-10
|
||||
- QM-11
|
||||
- QM-13
|
||||
- QM-25
|
||||
- QM-26
|
||||
- QM-27
|
||||
- QM-28
|
||||
- QM-29
|
||||
external_refs: []
|
||||
source:
|
||||
framework: BSI QUAIDAL
|
||||
section: MA-12
|
||||
title_original_de: MA-12 Abdeckung relevanter Merkmale
|
||||
url: https://github.com/BSI-Bund/QUAIDAL/blob/main/0000_Markdown/0001_Criteria,Measurements,Metrics/0002_Maßnahmen/MA-12_RelevantFeatureCoverage.md
|
||||
commit_sha: c39b75369841b359c6bf56d6588e3768c722842f
|
||||
license_note: § 5 UrhG anwendbar; share:true im Frontmatter; Clean-Room-Ableitung.
|
||||
plagiarism_score_at_generation: 0.0
|
||||
- id: MIT-AI-DATA-MA-13-vollstaendige-information-in-datensaetze
|
||||
canonical_name: Vollständige Information in Datensätzen
|
||||
description: Für die Validierung von KI-Trainingsdaten ist sicherzustellen, dass
|
||||
alle für die Analyse erforderlichen Attribute vollständig vorliegen und keine
|
||||
unbeabsichtigten Lücken existieren. Bei festgestellten Datenfehlern ist zwingend
|
||||
die Ursache zu ermitteln, um das passende Imputationsverfahren basierend auf dem
|
||||
spezifischen Fehlerschema auszuwählen. Eine unzureichende Datenbasis darf nicht
|
||||
zur Modellierung genutzt werden, solange die Integrität der relevanten Information
|
||||
nicht durch geeignete Maßnahmen wiederhergestellt wurde.
|
||||
kind: measure
|
||||
regulation_anchor: EU AI Act Art. 10 (Datenqualität für Hochrisiko-KI)
|
||||
related_quaidal_ids:
|
||||
- QM-12
|
||||
- QM-40
|
||||
- QM-53
|
||||
external_refs: []
|
||||
source:
|
||||
framework: BSI QUAIDAL
|
||||
section: MA-13
|
||||
title_original_de: MA-13 Vollständige Information in Datensätzen
|
||||
url: https://github.com/BSI-Bund/QUAIDAL/blob/main/0000_Markdown/0001_Criteria,Measurements,Metrics/0002_Maßnahmen/MA-13_CompleteInformation.md
|
||||
commit_sha: c39b75369841b359c6bf56d6588e3768c722842f
|
||||
license_note: § 5 UrhG anwendbar; share:true im Frontmatter; Clean-Room-Ableitung.
|
||||
plagiarism_score_at_generation: 0.0
|
||||
- id: MIT-AI-DATA-MA-14-eda-explorative-daten-analyse
|
||||
canonical_name: EDA-Explorative Daten Analyse
|
||||
description: Vor Beginn des Modelltrainings ist eine explorative Datenanalyse durchzuführen,
|
||||
um Datenverteilungen, Korrelationen sowie Ausreißer und strukturelle Anomalien
|
||||
ohne vorab definierte Hypothesen zu identifizieren. Die gewonnenen Erkenntnisse
|
||||
sind systematisch zu dokumentieren, um die Qualität der Trainingsdaten zu validieren
|
||||
und fundierte Entscheidungen über notwendige Bereinigungs- oder Erweiterungsschritte
|
||||
abzuleiten. Auf Basis dieser Analyse ist der Datensatz so anzupassen, dass er
|
||||
die für die Zielfunktion erforderliche Repräsentativität und Integrität gewährleistet.
|
||||
kind: measure
|
||||
regulation_anchor: EU AI Act Art. 10 (Datenqualität für Hochrisiko-KI)
|
||||
related_quaidal_ids:
|
||||
- QM-10
|
||||
- QM-12
|
||||
- QM-24
|
||||
- QM-25
|
||||
- QM-26
|
||||
- QM-27
|
||||
- QM-28
|
||||
- QM-29
|
||||
- QM-36
|
||||
- QM-42
|
||||
- QM-54
|
||||
- QM-57
|
||||
- QM-61
|
||||
external_refs: []
|
||||
source:
|
||||
framework: BSI QUAIDAL
|
||||
section: MA-14
|
||||
title_original_de: MA-14 EDA-Explorative Daten Analyse
|
||||
url: https://github.com/BSI-Bund/QUAIDAL/blob/main/0000_Markdown/0001_Criteria,Measurements,Metrics/0002_Maßnahmen/MA-14_EDA-ExplorativeDataAnalysis.md
|
||||
commit_sha: c39b75369841b359c6bf56d6588e3768c722842f
|
||||
license_note: § 5 UrhG anwendbar; share:true im Frontmatter; Clean-Room-Ableitung.
|
||||
plagiarism_score_at_generation: 0.0
|
||||
- id: MIT-AI-DATA-MA-15-empirische-evidenz
|
||||
canonical_name: Empirische Evidenz
|
||||
description: Es ist sicherzustellen, dass die Wirksamkeit von Schutzmaßnahmen gegen
|
||||
KI-gestützte Angriffe durch den systematischen Vergleich mit historischen Einsatzszenarien
|
||||
empirisch validiert wird. Dabei sind Leistungsdaten aus vergleichbaren Anwendungsfällen
|
||||
heranzuziehen, um die Angemessenheit der eingesetzten Trainingsdatensätze und
|
||||
Methoden für den spezifischen Kontext nachzuweisen. Die Analyse muss belegen,
|
||||
dass die gewählten Maßnahmen die identifizierten Risiken in der Praxis effektiv
|
||||
reduzieren und die Datenqualität den aktuellen Bedrohungsmodellen entspricht.
|
||||
kind: measure
|
||||
regulation_anchor: EU AI Act Art. 10 (Datenqualität für Hochrisiko-KI)
|
||||
related_quaidal_ids:
|
||||
- QM-16
|
||||
- QM-30
|
||||
- QM-61
|
||||
external_refs: []
|
||||
source:
|
||||
framework: BSI QUAIDAL
|
||||
section: MA-15
|
||||
title_original_de: MA-15 Empirische Evidenz
|
||||
url: https://github.com/BSI-Bund/QUAIDAL/blob/main/0000_Markdown/0001_Criteria,Measurements,Metrics/0002_Maßnahmen/MA-15_EmpiricEvidence.md
|
||||
commit_sha: c39b75369841b359c6bf56d6588e3768c722842f
|
||||
license_note: § 5 UrhG anwendbar; share:true im Frontmatter; Clean-Room-Ableitung.
|
||||
plagiarism_score_at_generation: 0.0
|
||||
- id: MIT-AI-DATA-MA-16-daten-imputation
|
||||
canonical_name: Daten Imputation
|
||||
description: Für KI-Trainingsdatensätze ist eine systematische Analyse der Ursachen
|
||||
für fehlende Werte zwingend erforderlich, bevor eine Rekonstruktion erfolgt. Das
|
||||
gewählte Verfahren zur Datenergänzung muss sich strikt an den identifizierten
|
||||
Entstehungsgründen orientieren, um die statistische Integrität des Modells zu
|
||||
wahren. Eine unkritische Imputation ohne Ursachenanalyse ist unzulässig, da sie
|
||||
das Lernverhalten des Algorithmus verfälschen kann.
|
||||
kind: measure
|
||||
regulation_anchor: EU AI Act Art. 10 (Datenqualität für Hochrisiko-KI)
|
||||
related_quaidal_ids:
|
||||
- MA-13
|
||||
- QM-10
|
||||
- QM-22
|
||||
- QM-44
|
||||
- QM-53
|
||||
external_refs: []
|
||||
source:
|
||||
framework: BSI QUAIDAL
|
||||
section: MA-16
|
||||
title_original_de: MA-16 Daten Imputation
|
||||
url: https://github.com/BSI-Bund/QUAIDAL/blob/main/0000_Markdown/0001_Criteria,Measurements,Metrics/0002_Maßnahmen/MA-16_DataImputation.md
|
||||
commit_sha: c39b75369841b359c6bf56d6588e3768c722842f
|
||||
license_note: § 5 UrhG anwendbar; share:true im Frontmatter; Clean-Room-Ableitung.
|
||||
plagiarism_score_at_generation: 0.0
|
||||
- id: MIT-AI-DATA-MA-17-metadatenverwaltung
|
||||
canonical_name: Metadatenverwaltung
|
||||
description: Für den KI-Trainingsprozess ist eine vollständige Dokumentation der
|
||||
Datenherkunft, der Qualitätsmetriken sowie der rechtlichen Klassifizierung jeder
|
||||
einzelnen Trainingsinstanz sicherzustellen. Diese strukturellen Begleitinformationen
|
||||
müssen maschinenlesbar vorliegen, um eine automatisierte Validierung der Datenintegrität
|
||||
und eine nachvollziehbare Auditierung des Datensatzes zu ermöglichen. Die Erfassung
|
||||
dieser Attribute ist zwingend erforderlich, um die Eignung der Daten für den spezifischen
|
||||
Trainingszweck zu gewährleisten und regulatorische Vorgaben einzuhalten.
|
||||
kind: measure
|
||||
regulation_anchor: EU AI Act Art. 10 (Datenqualität für Hochrisiko-KI)
|
||||
related_quaidal_ids:
|
||||
- QM-59
|
||||
external_refs: []
|
||||
source:
|
||||
framework: BSI QUAIDAL
|
||||
section: MA-17
|
||||
title_original_de: MA-17 Metadatenverwaltung
|
||||
url: https://github.com/BSI-Bund/QUAIDAL/blob/main/0000_Markdown/0001_Criteria,Measurements,Metrics/0002_Maßnahmen/MA-17_MetadataManagement.md
|
||||
commit_sha: c39b75369841b359c6bf56d6588e3768c722842f
|
||||
license_note: § 5 UrhG anwendbar; share:true im Frontmatter; Clean-Room-Ableitung.
|
||||
plagiarism_score_at_generation: 0.0
|
||||
- id: MIT-AI-DATA-MA-18-provenienztracking
|
||||
canonical_name: ProvenienzTracking
|
||||
description: Die Herkunft und der Verarbeitungsweg von KI-Trainingsdaten sind lückenlos
|
||||
zu dokumentieren, um deren Integrität und Nachvollziehbarkeit sicherzustellen.
|
||||
Für jeden Datensatz ist eine eindeutige Identifikation des Ursprungs sowie aller
|
||||
Transformationsschritte im Lebenszyklus zu führen. Diese Metadaten müssen so strukturiert
|
||||
sein, dass eine Rückverfolgung zur ursprünglichen Quelle jederzeit möglich ist,
|
||||
ohne dass Datenverluste oder Manipulationen unentdeckt bleiben.
|
||||
kind: measure
|
||||
regulation_anchor: EU AI Act Art. 10 (Datenqualität für Hochrisiko-KI)
|
||||
related_quaidal_ids:
|
||||
- QM-59
|
||||
- QM-60
|
||||
- QM-61
|
||||
- QM-65
|
||||
- QM-67
|
||||
- QM-70
|
||||
external_refs: []
|
||||
source:
|
||||
framework: BSI QUAIDAL
|
||||
section: MA-18
|
||||
title_original_de: MA-18 ProvenienzTracking
|
||||
url: https://github.com/BSI-Bund/QUAIDAL/blob/main/0000_Markdown/0001_Criteria,Measurements,Metrics/0002_Maßnahmen/MA-18_ProvenienzTracking.md
|
||||
commit_sha: c39b75369841b359c6bf56d6588e3768c722842f
|
||||
license_note: § 5 UrhG anwendbar; share:true im Frontmatter; Clean-Room-Ableitung.
|
||||
plagiarism_score_at_generation: 0.0
|
||||
- id: MIT-AI-DATA-MA-19-audit-trails
|
||||
canonical_name: Audit Trails
|
||||
description: Für die Nachvollziehbarkeit von KI-Trainingsprozessen ist ein lückenloses
|
||||
Protokollierungssystem zu implementieren, das alle Datenmanipulationen und Modellupdates
|
||||
zeitgestempelt erfasst. Jeder Zugriff auf Trainingsdatensätze sowie jede Änderung
|
||||
der Modellparameter muss mit eindeutigen Benutzeridentitäten verknüpft werden.
|
||||
Die gespeicherten Logs müssen so strukturiert sein, dass sie eine vollständige
|
||||
Rekonstruktion des Datenflusses und eine Rückführung auf frühere Datenqualitätszustände
|
||||
ermöglichen.
|
||||
kind: measure
|
||||
regulation_anchor: EU AI Act Art. 10 (Datenqualität für Hochrisiko-KI)
|
||||
related_quaidal_ids:
|
||||
- MA-22
|
||||
external_refs: []
|
||||
source:
|
||||
framework: BSI QUAIDAL
|
||||
section: MA-19
|
||||
title_original_de: MA-19 Audit Trails
|
||||
url: https://github.com/BSI-Bund/QUAIDAL/blob/main/0000_Markdown/0001_Criteria,Measurements,Metrics/0002_Maßnahmen/MA-19_AuditTrails.md
|
||||
commit_sha: c39b75369841b359c6bf56d6588e3768c722842f
|
||||
license_note: § 5 UrhG anwendbar; share:true im Frontmatter; Clean-Room-Ableitung.
|
||||
plagiarism_score_at_generation: 0.0
|
||||
- id: MIT-AI-DATA-MA-20-prozess-dokumentation
|
||||
canonical_name: Prozess Dokumentation
|
||||
description: Für die Sicherstellung der Datenqualität im KI-Trainingsprozess ist
|
||||
eine vollständige Dokumentation aller Phasen der Datenerstellung und -aufbereitung
|
||||
zwingend erforderlich. Diese Spezifikation muss verbindlich festlegen, welche
|
||||
Aktivitäten auszuführen sind, wer hierfür verantwortlich zeichnet, welche Ressourcen
|
||||
notwendig sind und welche qualitativen Ergebnisse zu erzielen sind. Insbesondere
|
||||
ist die Nachverfolgbarkeit der Datenherkunft innerhalb des Dokumentationsprozesses
|
||||
lückenlos zu gewährleisten, um die Integrität der Trainingsdaten zu validieren.
|
||||
kind: measure
|
||||
regulation_anchor: EU AI Act Art. 10 (Datenqualität für Hochrisiko-KI)
|
||||
related_quaidal_ids:
|
||||
- QM-15
|
||||
- QM-31
|
||||
- QM-62
|
||||
- QM-65
|
||||
external_refs:
|
||||
- framework: ISO/IEC 42001
|
||||
citation: null
|
||||
source:
|
||||
framework: BSI QUAIDAL
|
||||
section: MA-20
|
||||
title_original_de: MA-20 Prozess Dokumentation
|
||||
url: https://github.com/BSI-Bund/QUAIDAL/blob/main/0000_Markdown/0001_Criteria,Measurements,Metrics/0002_Maßnahmen/MA-20_ProcessDocumentation.md
|
||||
commit_sha: c39b75369841b359c6bf56d6588e3768c722842f
|
||||
license_note: § 5 UrhG anwendbar; share:true im Frontmatter; Clean-Room-Ableitung.
|
||||
plagiarism_score_at_generation: 0.0
|
||||
- id: MIT-AI-DATA-MA-21-compliance
|
||||
canonical_name: Compliance
|
||||
description: Der Einsatz von KI-Modellen erfordert eine zwingende Prüfung der Trainingsdatensätze
|
||||
auf rechtliche Konformität und ethische Integrität, bevor diese zur Modellgenerierung
|
||||
verwendet werden. Es ist sicherzustellen, dass alle verarbeiteten Informationen
|
||||
die Vorgaben der DSGVO sowie branchenspezifische Regularien vollständig erfüllen
|
||||
und keine unrechtmäßig beschafften oder personenbezogenen Daten ohne explizite
|
||||
Einwilligung enthalten. Die Validierung dieser Datenqualität muss vor jedem Trainingslauf
|
||||
durch einen automatisierten oder manuellen Compliance-Check nachgewiesen werden.
|
||||
kind: measure
|
||||
regulation_anchor: EU AI Act Art. 10 (Datenqualität für Hochrisiko-KI)
|
||||
related_quaidal_ids:
|
||||
- QM-12
|
||||
- QM-15
|
||||
external_refs:
|
||||
- framework: EU GDPR
|
||||
citation: null
|
||||
- framework: AI Act
|
||||
citation: null
|
||||
source:
|
||||
framework: BSI QUAIDAL
|
||||
section: MA-21
|
||||
title_original_de: MA-21 Compliance
|
||||
url: https://github.com/BSI-Bund/QUAIDAL/blob/main/0000_Markdown/0001_Criteria,Measurements,Metrics/0002_Maßnahmen/MA-21_Compliance.md
|
||||
commit_sha: c39b75369841b359c6bf56d6588e3768c722842f
|
||||
license_note: § 5 UrhG anwendbar; share:true im Frontmatter; Clean-Room-Ableitung.
|
||||
plagiarism_score_at_generation: 0.0
|
||||
- id: MIT-AI-DATA-MA-22-vertrauenswuerdigkeit
|
||||
canonical_name: Vertrauenswürdigkeit
|
||||
description: Die Integrität und Zuverlässigkeit der für das KI-Training verwendeten
|
||||
Datensätze ist im jeweiligen Anwendungskontext nachweislich zu verifizieren. Es
|
||||
ist sicherzustellen, dass potenzielle Manipulationen oder unbeabsichtigte Korruptionen
|
||||
des Datenflusses durch technische Prüfmechanismen ausgeschlossen werden. Bei der
|
||||
Anwendung von Korrekturverfahren zur Datenbereinigung muss die ursprüngliche Glaubwürdigkeit
|
||||
der Informationen gewahrt bleiben und darf nicht durch die Maßnahme beeinträchtigt
|
||||
werden.
|
||||
kind: measure
|
||||
regulation_anchor: EU AI Act Art. 10 (Datenqualität für Hochrisiko-KI)
|
||||
related_quaidal_ids:
|
||||
- QM-15
|
||||
- QM-43
|
||||
- QM-65
|
||||
external_refs: []
|
||||
source:
|
||||
framework: BSI QUAIDAL
|
||||
section: MA-22
|
||||
title_original_de: MA-22 Vertrauenswürdigkeit
|
||||
url: https://github.com/BSI-Bund/QUAIDAL/blob/main/0000_Markdown/0001_Criteria,Measurements,Metrics/0002_Maßnahmen/MA-22_Credibility.md
|
||||
commit_sha: c39b75369841b359c6bf56d6588e3768c722842f
|
||||
license_note: § 5 UrhG anwendbar; share:true im Frontmatter; Clean-Room-Ableitung.
|
||||
plagiarism_score_at_generation: 0.0
|
||||
- id: MIT-AI-DATA-MA-23-merkmalsskalierung
|
||||
canonical_name: Merkmalsskalierung
|
||||
description: Für KI-Trainingsdatensätze ist eine Normalisierung der Merkmalswerte
|
||||
auf einen einheitlichen Wertebereich zwingend erforderlich, um Dominanzeffekte
|
||||
durch unterschiedliche Größenordnungen zu vermeiden. Diese Maßnahme stellt sicher,
|
||||
dass Algorithmen, die auf Distanzberechnungen oder Gradientenverfahren basieren,
|
||||
nicht durch skalenbedingte Verzerrungen beeinträchtigt werden. Die Wirksamkeit
|
||||
der Skalierung ist vor dem Training systematisch zu prüfen, um die Vorhersagegenauigkeit
|
||||
des Modells zu garantieren.
|
||||
kind: measure
|
||||
regulation_anchor: EU AI Act Art. 10 (Datenqualität für Hochrisiko-KI)
|
||||
related_quaidal_ids:
|
||||
- QM-10
|
||||
- QM-56
|
||||
external_refs: []
|
||||
source:
|
||||
framework: BSI QUAIDAL
|
||||
section: MA-23
|
||||
title_original_de: MA-23 Merkmalsskalierung
|
||||
url: https://github.com/BSI-Bund/QUAIDAL/blob/main/0000_Markdown/0001_Criteria,Measurements,Metrics/0002_Maßnahmen/MA-23_FeatureScaling.md
|
||||
commit_sha: c39b75369841b359c6bf56d6588e3768c722842f
|
||||
license_note: § 5 UrhG anwendbar; share:true im Frontmatter; Clean-Room-Ableitung.
|
||||
plagiarism_score_at_generation: 0.0
|
||||
- id: MIT-AI-DATA-MA-24-merkmalserstellung
|
||||
canonical_name: Merkmalserstellung
|
||||
description: Es ist sicherzustellen, dass bei der Erstellung neuer Eingangsmerkmale
|
||||
für KI-Modelle ausschließlich validierte Transformationsverfahren angewendet werden,
|
||||
um die Datenqualität zu gewährleisten. Die Generierung neuer Features muss auf
|
||||
nachvollziehbaren Algorithmen basieren, die eine signifikante Verbesserung der
|
||||
Modellleistung gegenüber den Rohdaten nachweisen. Jede angewandte Methode zur
|
||||
Datenanreicherung oder -bereinigung ist vor dem Training auf ihre Eignung zur
|
||||
Mustererkennung und Vorhersagegenauigkeit zu prüfen.
|
||||
kind: measure
|
||||
regulation_anchor: EU AI Act Art. 10 (Datenqualität für Hochrisiko-KI)
|
||||
related_quaidal_ids:
|
||||
- QM-11
|
||||
- QM-25
|
||||
- QM-26
|
||||
- QM-27
|
||||
- QM-28
|
||||
- QM-51
|
||||
- QM-71
|
||||
external_refs: []
|
||||
source:
|
||||
framework: BSI QUAIDAL
|
||||
section: MA-24
|
||||
title_original_de: MA-24 Merkmalserstellung
|
||||
url: https://github.com/BSI-Bund/QUAIDAL/blob/main/0000_Markdown/0001_Criteria,Measurements,Metrics/0002_Maßnahmen/MA-24_FeatureCreation.md
|
||||
commit_sha: c39b75369841b359c6bf56d6588e3768c722842f
|
||||
license_note: § 5 UrhG anwendbar; share:true im Frontmatter; Clean-Room-Ableitung.
|
||||
plagiarism_score_at_generation: 0.0
|
||||
- id: MIT-AI-DATA-MA-25-differential-privacy
|
||||
canonical_name: Differential Privacy
|
||||
description: Das System muss bei der Verarbeitung von KI-Trainingsdaten differenzielle
|
||||
Privatsphäre implementieren, indem statistisch signifikante, zufällige Störgrößen
|
||||
zu den Ergebnissen hinzugefügt werden. Es ist sicherzustellen, dass die An- oder
|
||||
Abwesenheit einzelner Datensätze im Trainingsset das Ausgabeergebnis nur marginal
|
||||
beeinflusst. Durch diese Maßnahme ist zu prüfen, ob keine Rückschlüsse auf spezifische
|
||||
Personen aus den generierten Analysen gezogen werden können, während die allgemeine
|
||||
Datenqualität für das Modelltraining erhalten bleibt.
|
||||
kind: measure
|
||||
regulation_anchor: EU AI Act Art. 10 (Datenqualität für Hochrisiko-KI)
|
||||
related_quaidal_ids:
|
||||
- QM-58
|
||||
external_refs: []
|
||||
source:
|
||||
framework: BSI QUAIDAL
|
||||
section: MA-25
|
||||
title_original_de: MA-25 Differential Privacy
|
||||
url: https://github.com/BSI-Bund/QUAIDAL/blob/main/0000_Markdown/0001_Criteria,Measurements,Metrics/0002_Maßnahmen/MA-25_Differential%20Privacy.md
|
||||
commit_sha: c39b75369841b359c6bf56d6588e3768c722842f
|
||||
license_note: § 5 UrhG anwendbar; share:true im Frontmatter; Clean-Room-Ableitung.
|
||||
plagiarism_score_at_generation: 0.0625
|
||||
- id: MIT-AI-DATA-MA-26-federated-learning
|
||||
canonical_name: Federated Learning
|
||||
description: Für KI-Systeme, die auf verteilten Datenquellen basieren, ist ein Federated-Learning-Ansatz
|
||||
zwingend vorzusehen, um die Rohdaten dezentral zu belassen. Die lokalen Modelle
|
||||
müssen ausschließlich aggregierte Parameter an eine zentrale Instanz übermitteln,
|
||||
während die ursprünglichen Trainingsdaten niemals die lokale Umgebung verlassen.
|
||||
Eine Prüfung ist sicherzustellen, dass durch diese Architektur keine sensiblen
|
||||
Informationen während des Lernprozesses zentralisiert oder übertragen werden.
|
||||
kind: measure
|
||||
regulation_anchor: EU AI Act Art. 10 (Datenqualität für Hochrisiko-KI)
|
||||
related_quaidal_ids:
|
||||
- QM-63
|
||||
external_refs: []
|
||||
source:
|
||||
framework: BSI QUAIDAL
|
||||
section: MA-26
|
||||
title_original_de: MA-26 Federated Learning
|
||||
url: https://github.com/BSI-Bund/QUAIDAL/blob/main/0000_Markdown/0001_Criteria,Measurements,Metrics/0002_Maßnahmen/MA-26_Federated%20Learning%20Approach.md
|
||||
commit_sha: c39b75369841b359c6bf56d6588e3768c722842f
|
||||
license_note: § 5 UrhG anwendbar; share:true im Frontmatter; Clean-Room-Ableitung.
|
||||
plagiarism_score_at_generation: 0.0
|
||||
- id: MIT-AI-DATA-MA-27-statistische-grundlagenthemen
|
||||
canonical_name: Statistische Grundlagenthemen
|
||||
description: Für die Sicherstellung der Datenqualität im KI-Lebenszyklus sind statistische
|
||||
Basisverfahren systematisch zu implementieren und kontinuierlich zu validieren.
|
||||
Es ist sicherzustellen, dass alle relevanten Metriken zur Verteilungsanalyse und
|
||||
Datenintegrität konsistent in die Berechnungspipelines integriert werden. Diese
|
||||
fundamentalen Analysen müssen unabhängig von spezifischen Bausteinen als übergeordnete
|
||||
Prüfkriterien für die Modellgüte dienen.
|
||||
kind: measure
|
||||
regulation_anchor: EU AI Act Art. 10 (Datenqualität für Hochrisiko-KI)
|
||||
related_quaidal_ids:
|
||||
- QM-01
|
||||
- QM-02
|
||||
- QM-03
|
||||
- QM-04
|
||||
- QM-06
|
||||
- QM-07
|
||||
- QM-09
|
||||
- QM-23
|
||||
- QM-51
|
||||
external_refs: []
|
||||
source:
|
||||
framework: BSI QUAIDAL
|
||||
section: MA-27
|
||||
title_original_de: MA-27 Statistische Grundlagenthemen
|
||||
url: https://github.com/BSI-Bund/QUAIDAL/blob/main/0000_Markdown/0001_Criteria,Measurements,Metrics/0002_Maßnahmen/MA-27_StatisticalBasis.md
|
||||
commit_sha: c39b75369841b359c6bf56d6588e3768c722842f
|
||||
license_note: § 5 UrhG anwendbar; share:true im Frontmatter; Clean-Room-Ableitung.
|
||||
plagiarism_score_at_generation: 0.0213
|
||||
- id: MIT-AI-DATA-MA-28-diversitaetsindizes
|
||||
canonical_name: Diversitätsindizes
|
||||
description: Das System muss quantitative Metriken zur Erfassung der Heterogenität
|
||||
von KI-Trainingsdaten implementieren, um die Verteilung verschiedener Kategorien
|
||||
zu messen. Es ist sicherzustellen, dass diese Kennzahlen sowohl die Anzahl vorhandener
|
||||
Klassen als auch deren Gleichverteilung abbilden. Die Validierung der Datenqualität
|
||||
erfolgt durch die Berechnung von Diversitätsindizes, die statistische Unsicherheit
|
||||
oder Kollisionswahrscheinlichkeiten quantifizieren.
|
||||
kind: measure
|
||||
regulation_anchor: EU AI Act Art. 10 (Datenqualität für Hochrisiko-KI)
|
||||
related_quaidal_ids:
|
||||
- QM-68
|
||||
external_refs: []
|
||||
source:
|
||||
framework: BSI QUAIDAL
|
||||
section: MA-28
|
||||
title_original_de: MA-28 Diversitätsindizes
|
||||
url: https://github.com/BSI-Bund/QUAIDAL/blob/main/0000_Markdown/0001_Criteria,Measurements,Metrics/0002_Maßnahmen/MA-28_Diversity-Indices.md
|
||||
commit_sha: c39b75369841b359c6bf56d6588e3768c722842f
|
||||
license_note: § 5 UrhG anwendbar; share:true im Frontmatter; Clean-Room-Ableitung.
|
||||
plagiarism_score_at_generation: 0.0
|
||||
- id: MIT-AI-DATA-MA-29-data-splitting
|
||||
canonical_name: Data-Splitting
|
||||
description: Die Aufteilung von KI-Trainingsdaten in disjunkte Teilmengen ist zwingend
|
||||
erforderlich, um eine unvoreingenommene Validierung der Modellgüte zu gewährleisten.
|
||||
Dabei müssen mindestens drei voneinander getrennte Bereiche für das Training,
|
||||
die Hyperparameter-Optimierung sowie die abschließende Leistungsbewertung definiert
|
||||
werden. Eine zufällige oder stratifizierte Trennung ist sicherzustellen, um Datenlecks
|
||||
zwischen den Phasen auszuschließen und die Generalisierungsfähigkeit des Systems
|
||||
nachweisbar zu prüfen.
|
||||
kind: measure
|
||||
regulation_anchor: EU AI Act Art. 10 (Datenqualität für Hochrisiko-KI)
|
||||
related_quaidal_ids:
|
||||
- QM-69
|
||||
external_refs: []
|
||||
source:
|
||||
framework: BSI QUAIDAL
|
||||
section: MA-29
|
||||
title_original_de: MA-29 Data-Splitting
|
||||
url: https://github.com/BSI-Bund/QUAIDAL/blob/main/0000_Markdown/0001_Criteria,Measurements,Metrics/0002_Maßnahmen/MA-29_Data%20Splitting.md
|
||||
commit_sha: c39b75369841b359c6bf56d6588e3768c722842f
|
||||
license_note: § 5 UrhG anwendbar; share:true im Frontmatter; Clean-Room-Ableitung.
|
||||
plagiarism_score_at_generation: 0.0
|
||||
- id: MIT-AI-DATA-MA-30-fairness
|
||||
canonical_name: Fairness
|
||||
description: Das System muss sicherstellen, dass KI-Trainingsdaten keine systematischen
|
||||
Verzerrungen bezüglich sensibler demografischer Merkmale aufweisen, um diskriminierende
|
||||
Vorhersagen zu vermeiden. Bei unzureichender Repräsentation von Teilgruppen sind
|
||||
präventive Aufbereitungsverfahren oder algorithmische Transformationsmethoden
|
||||
zur Bias-Korrektur zwingend anzuwenden. Die Wirksamkeit dieser Maßnahmen ist vor
|
||||
der Modellbereitstellung durch quantitative Prüfverfahren auf Gleichbehandlungsgrundsätze
|
||||
zu validieren.
|
||||
kind: measure
|
||||
regulation_anchor: EU AI Act Art. 10 (Datenqualität für Hochrisiko-KI)
|
||||
related_quaidal_ids:
|
||||
- QM-57
|
||||
external_refs: []
|
||||
source:
|
||||
framework: BSI QUAIDAL
|
||||
section: MA-30
|
||||
title_original_de: MA-30 Fairness
|
||||
url: https://github.com/BSI-Bund/QUAIDAL/blob/main/0000_Markdown/0001_Criteria,Measurements,Metrics/0002_Maßnahmen/MA-30_Fairness.md
|
||||
commit_sha: c39b75369841b359c6bf56d6588e3768c722842f
|
||||
license_note: § 5 UrhG anwendbar; share:true im Frontmatter; Clean-Room-Ableitung.
|
||||
plagiarism_score_at_generation: 0.0
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,83 @@
|
||||
# Lizenzregeln der Control-Pipeline
|
||||
|
||||
> **Stand:** 2026-05-21 — Mapping festgezurrt nach DB-Inspektion und IACE-Audit.
|
||||
>
|
||||
> Die Pipeline klassifiziert jede Regulation (und damit jedes daraus extrahierte
|
||||
> Chunk und jeden atomic_control) in eine von **drei Lizenzregeln**. Die Regel
|
||||
> entscheidet, ob der Volltext aufbewahrt werden darf und welche Attribution im
|
||||
> Ausgabe-Renderer Pflicht ist.
|
||||
|
||||
## Die drei Regeln
|
||||
|
||||
| Regel | Bedeutung | Volltext speichern? | Attribution Pflicht? | Beispiele |
|
||||
|-------|-----------|---------------------|----------------------|-----------|
|
||||
| **1** | Wörtlich — Hoheitsrecht / Public Domain | ✓ | nein (empfohlen für Audit) | EU-Recht (EUR-Lex), Bundesrecht, Satzungsrecht (DGUV UVV), TRBS, TRGS, ASR, US Federal Code (OSHA), NIST SP, EU-Leitfäden |
|
||||
| **2** | Wörtlich mit Attribution — freie Lizenzen | ✓ | **ja** | OWASP (CC-BY-SA-4.0), OECD AI Principles (OECD_PUBLIC), ENISA-Dokumente (CC-BY-4.0), Apache-2.0 Werke |
|
||||
| **3** | Nur zitieren — proprietäre Standards | ✗ | nicht anwendbar (kein Volltext) | DIN, EN, ISO, ANSI, UL, IEC, IEEE, DGUV Regeln/Informationen/Grundsätze, Bitkom-Leitfäden, BSI-Bausteine (urheberrechtlich) |
|
||||
|
||||
**Wichtige Klarstellung:** Regel 3 = "nur Identifier/Abschnitt zitieren", **nicht** "umformulieren". Die ursprüngliche Bezeichnung "neu formulieren" war irreführend. Korrekt: Bei Regel-3-Quellen darf die Pipeline den Volltext nicht speichern; sie bewahrt nur die Quellenreferenz (regulation_id + article/paragraph), und der Output-Renderer zeigt diese Referenz im Frontend/PDF.
|
||||
|
||||
## Mapping `license_type` → `license_rule`
|
||||
|
||||
| license_type | license_rule | Erklärung |
|
||||
|---|---|---|
|
||||
| `EU_LAW`, `EU_PUBLIC` | 1 | EU-Verordnungen, Richtlinien, OJ-Veröffentlichungen, EU-Leitfäden |
|
||||
| `DE_LAW`, `DE_PUBLIC` | 1 | Bundesgesetze, TRBS, TRGS, ASR, DGUV-UVV (Satzungsrecht) |
|
||||
| `AT_LAW`, `CH_LAW`, `FR_LAW`, `IT_LAW`, `ES_LAW`, `NL_LAW`, `HU_LAW` | 1 | Andere EU-Mitgliedsstaaten-Recht |
|
||||
| `US_GOV_PUBLIC`, `NIST_PUBLIC_DOMAIN`, `OSHA_PUBLIC` | 1 | US Federal Code (17 U.S.C. §105 Public Domain) |
|
||||
| `CC-BY-4.0`, `CC-BY-SA-4.0`, `CC-BY-3.0`, `CC-BY-SA-3.0` | 2 | Creative-Commons mit Attribution-Pflicht |
|
||||
| `Apache-2.0`, `MIT` | 2 | Permissive OSS-Lizenzen, NOTICE-Pflicht |
|
||||
| `OECD_PUBLIC`, `ENISA_CC_BY_4.0` | 2 | Behörden-Publikationen mit Attribution-Auflage |
|
||||
| `DIN_COPYRIGHT`, `ISO_COPYRIGHT`, `ANSI_COPYRIGHT`, `UL_COPYRIGHT`, `IEC_COPYRIGHT` | 3 | Normungsorganisationen — nur Identifier-Zitat |
|
||||
| `DGUV_COPYRIGHT` | 3 | DGUV Regeln/Informationen/Grundsätze (nicht UVV) |
|
||||
| `BITKOM_COPYRIGHT`, `BSI_COPYRIGHT`, `VDMA_COPYRIGHT` | 3 | Verbands-/Behörden-Publikationen mit eigenständigem Urheberrecht |
|
||||
| `OWN_WORK` | 3 | BreakPilot-Eigentexte (Templates, eigene Patterns) — kein externes Lizenzrisiko, aber auch kein Public-Domain-Status |
|
||||
|
||||
**Sonderfall DGUV:** Die Klasse trennt sich nach Publikationstyp:
|
||||
- DGUV **Vorschriften / UVV** → `DE_LAW` → Regel 1
|
||||
- DGUV **Regeln, Informationen, Grundsätze** → `DGUV_COPYRIGHT` → Regel 3
|
||||
|
||||
## Auswirkung pro Pipeline-Stage
|
||||
|
||||
| Stage | Verhalten bei Regel 1 | Regel 2 | Regel 3 |
|
||||
|---|---|---|---|
|
||||
| Stage 6 ControlCompose (`pipeline_adapter.py:147`) | speichert `chunk_text` | speichert `chunk_text` | speichert `chunk_text = None` |
|
||||
| Atomic-Control-Bildung | Volltext als Quelle | Volltext + Attribution-Vermerk | nur regulation_id + article |
|
||||
| Output-Renderer (Frontend/PDF) | optionaler Quellen-Hinweis | **Pflicht-Attribution in Footer + Inline** | nur Identifier rendern |
|
||||
| Tech-File-Anhang | Quelle nennen | Quelle + Lizenz-URL | Identifier-Liste |
|
||||
|
||||
## Quellen ohne Klassifikation
|
||||
|
||||
Aktuell sind in `regulation_registry` **232 Regulationen** klassifiziert (Stand 2026-05-21). Die folgenden müssen noch ergänzt werden (Task #20 deckt den DGUV-Ingest):
|
||||
|
||||
| Quelle | Regel | Begründung |
|
||||
|---|---|---|
|
||||
| TRBS-Familie (24 PDFs im RAG) | 1 | Technische Regeln Betriebssicherheit — BAuA Bundesarbeitsblatt |
|
||||
| TRGS-Familie (alle Volltext-Chunks) | 1 | Technische Regeln Gefahrstoffe — BAuA |
|
||||
| ASR-Familie (17 PDFs) | 1 | Arbeitsstättenregeln — BAuA |
|
||||
| OSHA 29 CFR 1910 Subpart O + Technical Manual | 1 | US Federal Public Domain (17 U.S.C. §105) |
|
||||
| DGUV Vorschrift 1 + UVV-Familie (sobald ingest) | 1 | Satzungsrecht der BG |
|
||||
| DGUV Regel 100-500 + Information 209-072/074/073 | 3 | DGUV-Copyright, nur Identifier |
|
||||
| DIN-Identifier-Tabelle (ohne Volltext) | 3 | DIN-Beuth-Copyright |
|
||||
| ANSI B11.0 + RIA R15.06 + UL 508A Identifier | 3 | ANSI/UL-Copyright |
|
||||
| ISO 12100/13849/13857 Identifier | 3 | ISO-Copyright |
|
||||
|
||||
## Audit-Pflicht
|
||||
|
||||
Vor jedem Ingest neuer Quellen:
|
||||
1. Lizenz prüfen (publikationen.dguv.de, EUR-Lex, etc.)
|
||||
2. license_type aus obiger Tabelle wählen — wenn nicht vorhanden, hier ergänzen
|
||||
3. license_rule wird daraus deterministisch abgeleitet
|
||||
4. Attribution-Text bei Regel 2 ist Pflichtfeld
|
||||
|
||||
Vor jedem Output:
|
||||
- Wenn ein atomic_control aus einer Regel-3-Quelle stammt: prüfen dass NUR Identifier gezeigt wird, niemals Volltext
|
||||
- Wenn aus Regel-2-Quelle: Attribution muss im PDF-Footer und im Frontend-Tooltip vorhanden sein
|
||||
- Wenn aus Regel-1-Quelle: empfohlen Quelle nennen für Auditierbarkeit
|
||||
|
||||
## Verweise
|
||||
|
||||
- Schema: `migrations/002_regulation_registry.sql`
|
||||
- Code: `services/regulation_registry.py`, `services/pipeline_adapter.py`
|
||||
- Seed-Script: `scripts/f1_migrate_regulation_registry.py`
|
||||
- Tests: `tests/test_regulation_registry.py` (assert: rule IN (1,2,3))
|
||||
@@ -0,0 +1,58 @@
|
||||
-- Migration 011: Derived Controls Library (Clean-Room MCs from external sources)
|
||||
-- Schema: compliance
|
||||
--
|
||||
-- Holds Master Controls + atomic controls + mitigations + metrics that were
|
||||
-- derived Clean-Room from external regulatory sources (BSI QUAIDAL today,
|
||||
-- Grundschutz++/CRA/NIST AI RMF next). Kept separate from the gpre2
|
||||
-- master_controls table because:
|
||||
-- 1) The shape is different (no object_group/phase concepts).
|
||||
-- 2) Source-Layer-Trennung: derivations from external IP must be cleanly
|
||||
-- separable from internally-generated artifacts.
|
||||
-- 3) Each row carries the licence + provenance for due diligence.
|
||||
--
|
||||
-- Run: ssh macmini "docker exec -i bp-core-postgres psql -U breakpilot -d breakpilot_db" \
|
||||
-- < control-pipeline/migrations/011_derived_controls.sql
|
||||
|
||||
SET search_path TO compliance, public;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS derived_controls (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
derived_id VARCHAR(200) UNIQUE NOT NULL, -- e.g. MC-AI-DATA-QKB-01-repraesentativitaet
|
||||
kind VARCHAR(30) NOT NULL, -- criterion | building_block | measure | metric
|
||||
canonical_name VARCHAR(300) NOT NULL,
|
||||
description TEXT NOT NULL, -- our own wording, never the original
|
||||
regulation_anchor TEXT, -- e.g. "EU AI Act Art. 10"
|
||||
related_quaidal_ids JSONB NOT NULL DEFAULT '[]', -- ["QB-03", "QB-04", ...]
|
||||
external_refs JSONB NOT NULL DEFAULT '[]', -- [{framework, citation}, ...]
|
||||
source_framework VARCHAR(80) NOT NULL, -- "BSI QUAIDAL"
|
||||
source_section VARCHAR(80) NOT NULL, -- "QKB-01"
|
||||
source_url TEXT,
|
||||
source_commit_sha VARCHAR(80),
|
||||
source_title_original TEXT, -- original title (label, not protected)
|
||||
source_license_note TEXT,
|
||||
plagiarism_score_at_generation NUMERIC(5,4), -- 0..1; gate was 0.20
|
||||
generated_by_model VARCHAR(80),
|
||||
yaml_path TEXT, -- pointer back to source YAML
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_derived_controls_kind ON derived_controls(kind);
|
||||
CREATE INDEX IF NOT EXISTS idx_derived_controls_source_framework ON derived_controls(source_framework);
|
||||
CREATE INDEX IF NOT EXISTS idx_derived_controls_source_section ON derived_controls(source_section);
|
||||
CREATE INDEX IF NOT EXISTS idx_derived_controls_related_quaidal_gin
|
||||
ON derived_controls USING GIN(related_quaidal_ids);
|
||||
|
||||
-- Trigger to keep updated_at fresh
|
||||
CREATE OR REPLACE FUNCTION trg_derived_controls_set_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS derived_controls_updated_at ON derived_controls;
|
||||
CREATE TRIGGER derived_controls_updated_at
|
||||
BEFORE UPDATE ON derived_controls
|
||||
FOR EACH ROW EXECUTE FUNCTION trg_derived_controls_set_updated_at();
|
||||
@@ -0,0 +1,170 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Upsert derived QUAIDAL controls from YAML into compliance.derived_controls.
|
||||
|
||||
Reads:
|
||||
control-pipeline/data/quaidal/master_controls.yaml
|
||||
control-pipeline/data/quaidal/atomic_controls.yaml
|
||||
control-pipeline/data/quaidal/mitigations.yaml
|
||||
control-pipeline/data/quaidal/metrics.yaml
|
||||
|
||||
Writes: compliance.derived_controls (idempotent UPSERT by derived_id)
|
||||
|
||||
Usage:
|
||||
# Mac Mini direct:
|
||||
python3 control-pipeline/scripts/apply_quaidal_to_db.py
|
||||
|
||||
# Via SSH (locally, against macmini DB):
|
||||
DB_HOST=macmini python3 control-pipeline/scripts/apply_quaidal_to_db.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import psycopg
|
||||
import yaml
|
||||
except ImportError as e:
|
||||
print(f"ERROR: missing dependency {e.name}. Install with: pip install psycopg[binary] pyyaml", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
DATA_DIR = REPO_ROOT / "control-pipeline" / "data" / "quaidal"
|
||||
|
||||
KIND_FILES = {
|
||||
"criterion": "master_controls.yaml",
|
||||
"building_block": "atomic_controls.yaml",
|
||||
"measure": "mitigations.yaml",
|
||||
"metric": "metrics.yaml",
|
||||
}
|
||||
|
||||
UPSERT_SQL = """
|
||||
INSERT INTO compliance.derived_controls (
|
||||
derived_id, kind, canonical_name, description, regulation_anchor,
|
||||
related_quaidal_ids, external_refs,
|
||||
source_framework, source_section, source_url, source_commit_sha,
|
||||
source_title_original, source_license_note,
|
||||
plagiarism_score_at_generation, generated_by_model, yaml_path
|
||||
) VALUES (
|
||||
%(derived_id)s, %(kind)s, %(canonical_name)s, %(description)s, %(regulation_anchor)s,
|
||||
%(related_quaidal_ids)s::jsonb, %(external_refs)s::jsonb,
|
||||
%(source_framework)s, %(source_section)s, %(source_url)s, %(source_commit_sha)s,
|
||||
%(source_title_original)s, %(source_license_note)s,
|
||||
%(plagiarism_score)s, %(generated_by_model)s, %(yaml_path)s
|
||||
)
|
||||
ON CONFLICT (derived_id) DO UPDATE SET
|
||||
kind = EXCLUDED.kind,
|
||||
canonical_name = EXCLUDED.canonical_name,
|
||||
description = EXCLUDED.description,
|
||||
regulation_anchor = EXCLUDED.regulation_anchor,
|
||||
related_quaidal_ids = EXCLUDED.related_quaidal_ids,
|
||||
external_refs = EXCLUDED.external_refs,
|
||||
source_framework = EXCLUDED.source_framework,
|
||||
source_section = EXCLUDED.source_section,
|
||||
source_url = EXCLUDED.source_url,
|
||||
source_commit_sha = EXCLUDED.source_commit_sha,
|
||||
source_title_original = EXCLUDED.source_title_original,
|
||||
source_license_note = EXCLUDED.source_license_note,
|
||||
plagiarism_score_at_generation = EXCLUDED.plagiarism_score_at_generation,
|
||||
generated_by_model = EXCLUDED.generated_by_model,
|
||||
yaml_path = EXCLUDED.yaml_path
|
||||
"""
|
||||
|
||||
|
||||
def load_yaml_records(yaml_path: Path) -> tuple[list[dict], str | None, str | None]:
|
||||
if not yaml_path.exists():
|
||||
return [], None, None
|
||||
data = yaml.safe_load(yaml_path.read_text(encoding="utf-8"))
|
||||
return data.get("controls", []), data.get("commit_sha"), data.get("generated_by_model")
|
||||
|
||||
|
||||
def to_row(ctrl: dict, yaml_path: Path, default_model: str | None, default_commit: str | None) -> dict:
|
||||
source = ctrl.get("source") or {}
|
||||
return {
|
||||
"derived_id": ctrl["id"],
|
||||
"kind": ctrl["kind"],
|
||||
"canonical_name": ctrl["canonical_name"],
|
||||
"description": ctrl["description"],
|
||||
"regulation_anchor": ctrl.get("regulation_anchor"),
|
||||
"related_quaidal_ids": json.dumps(ctrl.get("related_quaidal_ids", []), ensure_ascii=False),
|
||||
"external_refs": json.dumps(ctrl.get("external_refs", []), ensure_ascii=False),
|
||||
"source_framework": source.get("framework", "BSI QUAIDAL"),
|
||||
"source_section": source.get("section", ""),
|
||||
"source_url": source.get("url"),
|
||||
"source_commit_sha": source.get("commit_sha") or default_commit,
|
||||
"source_title_original": source.get("title_original_de"),
|
||||
"source_license_note": source.get("license_note"),
|
||||
"plagiarism_score": ctrl.get("plagiarism_score_at_generation"),
|
||||
"generated_by_model": default_model,
|
||||
"yaml_path": str(yaml_path.relative_to(REPO_ROOT)),
|
||||
}
|
||||
|
||||
|
||||
def build_dsn(args: argparse.Namespace) -> str:
|
||||
if args.dsn:
|
||||
return args.dsn
|
||||
return (
|
||||
f"host={args.db_host} port={args.db_port} "
|
||||
f"dbname={args.db_name} user={args.db_user} password={args.db_password}"
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("--dsn", help="Full DSN; overrides individual flags")
|
||||
ap.add_argument("--db-host", default=os.environ.get("DB_HOST", "localhost"))
|
||||
ap.add_argument("--db-port", default=os.environ.get("DB_PORT", "5432"))
|
||||
ap.add_argument("--db-name", default=os.environ.get("DB_NAME", "breakpilot_db"))
|
||||
ap.add_argument("--db-user", default=os.environ.get("DB_USER", "breakpilot"))
|
||||
ap.add_argument("--db-password", default=os.environ.get("DB_PASSWORD", "breakpilot"))
|
||||
ap.add_argument("--dry-run", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
total = 0
|
||||
rows: list[dict] = []
|
||||
for kind, fname in KIND_FILES.items():
|
||||
path = DATA_DIR / fname
|
||||
records, commit, model = load_yaml_records(path)
|
||||
for rec in records:
|
||||
rows.append(to_row(rec, path, model, commit))
|
||||
if records:
|
||||
print(f" {fname}: {len(records)} entries", file=sys.stderr)
|
||||
total += len(records)
|
||||
|
||||
if not rows:
|
||||
print("ERROR: no YAML records found; run derive_quaidal_mcs.py first", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
print(f"Total rows: {total}", file=sys.stderr)
|
||||
if args.dry_run:
|
||||
print("Dry run — sample row:", file=sys.stderr)
|
||||
print(json.dumps({k: (v[:200] if isinstance(v, str) else v) for k, v in rows[0].items()}, indent=2, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
dsn = build_dsn(args)
|
||||
print(f"Connecting to {args.db_host}:{args.db_port}/{args.db_name}", file=sys.stderr)
|
||||
inserted = updated = 0
|
||||
with psycopg.connect(dsn) as conn:
|
||||
with conn.cursor() as cur:
|
||||
for row in rows:
|
||||
cur.execute(
|
||||
"SELECT 1 FROM compliance.derived_controls WHERE derived_id = %s",
|
||||
(row["derived_id"],),
|
||||
)
|
||||
existed = cur.fetchone() is not None
|
||||
cur.execute(UPSERT_SQL, row)
|
||||
if existed:
|
||||
updated += 1
|
||||
else:
|
||||
inserted += 1
|
||||
conn.commit()
|
||||
print(f"Inserted: {inserted}, Updated: {updated}", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,256 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Audit script for license classification gaps in the control pipeline.
|
||||
|
||||
Reports:
|
||||
|
||||
1. **regulation_registry coverage** — how many regulations are classified, by
|
||||
rule and license_type.
|
||||
2. **atomic_controls without license_rule** — how many controls reference a
|
||||
regulation_id that has no entry (or no license_rule) in the registry.
|
||||
3. **Qdrant payload consistency** — for each indexed collection, how many
|
||||
chunks carry both ``license`` and ``license_rule`` payload fields.
|
||||
|
||||
The goal is to surface every record where the engine could in principle
|
||||
extract or emit content but the license rule is unknown — those records are
|
||||
the highest-risk material in a license audit.
|
||||
|
||||
Usage::
|
||||
|
||||
python3 scripts/audit_license_classification.py --db-host 100.80.114.48
|
||||
|
||||
Add ``--check-qdrant`` to also probe ``http://<host>:6333`` collections.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from urllib import request as urllib_request
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
DEFAULT_HOST = "100.80.114.48"
|
||||
DEFAULT_PORT = 5432
|
||||
DEFAULT_USER = "breakpilot"
|
||||
DEFAULT_DB = "breakpilot_db"
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
p = argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument("--db-host", default=DEFAULT_HOST)
|
||||
p.add_argument("--db-port", type=int, default=DEFAULT_PORT)
|
||||
p.add_argument("--db-user", default=DEFAULT_USER)
|
||||
p.add_argument("--db-name", default=DEFAULT_DB)
|
||||
p.add_argument("--db-password", default="")
|
||||
p.add_argument("--check-qdrant", action="store_true")
|
||||
p.add_argument("--qdrant-host", default="100.80.114.48")
|
||||
p.add_argument("--qdrant-port", type=int, default=6333)
|
||||
p.add_argument("--json", action="store_true", help="Emit JSON result on stdout")
|
||||
return p.parse_args()
|
||||
|
||||
|
||||
def audit_registry(conn) -> dict:
|
||||
"""Coverage of regulation_registry."""
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SET search_path TO compliance, public; "
|
||||
"SELECT license_rule, license_type, COUNT(*) "
|
||||
"FROM regulation_registry GROUP BY license_rule, license_type "
|
||||
"ORDER BY license_rule, license_type;"
|
||||
)
|
||||
by_rule_and_type: list[tuple] = []
|
||||
by_rule: Counter = Counter()
|
||||
for rule, ltype, count in cur.fetchall():
|
||||
by_rule_and_type.append((rule, ltype or "(empty)", count))
|
||||
by_rule[rule] += count
|
||||
|
||||
cur.execute(
|
||||
"SELECT COUNT(*) FROM regulation_registry "
|
||||
"WHERE license_type IS NULL OR license_type = '';"
|
||||
)
|
||||
missing_type = cur.fetchone()[0]
|
||||
|
||||
cur.execute("SELECT COUNT(*) FROM regulation_registry;")
|
||||
total = cur.fetchone()[0]
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"by_rule": dict(by_rule),
|
||||
"by_rule_and_type": by_rule_and_type,
|
||||
"missing_license_type": missing_type,
|
||||
}
|
||||
|
||||
|
||||
def audit_atomic_controls(conn) -> dict:
|
||||
"""Controls whose source regulation has no license rule.
|
||||
|
||||
Important: the schema differs between core (bp-core) and customer
|
||||
deployments. We probe a handful of likely column names and skip if
|
||||
none are found.
|
||||
"""
|
||||
cur = conn.cursor()
|
||||
# Detect controls table
|
||||
cur.execute(
|
||||
"SELECT table_name FROM information_schema.tables "
|
||||
"WHERE table_schema='compliance' AND table_name IN "
|
||||
"('atomic_controls','atomic_controls_dedup','canonical_controls');"
|
||||
)
|
||||
tables = [r[0] for r in cur.fetchall()]
|
||||
if not tables:
|
||||
return {"skipped": True, "reason": "no controls table found"}
|
||||
|
||||
result: dict = {"tables": {}}
|
||||
for tbl in tables:
|
||||
cur.execute(
|
||||
f"SELECT column_name FROM information_schema.columns "
|
||||
f"WHERE table_schema='compliance' AND table_name='{tbl}';"
|
||||
)
|
||||
cols = {r[0] for r in cur.fetchall()}
|
||||
if "license_rule" not in cols:
|
||||
result["tables"][tbl] = {"skipped": True, "reason": "no license_rule column"}
|
||||
continue
|
||||
cur.execute(f"SELECT COUNT(*) FROM compliance.{tbl};")
|
||||
total = cur.fetchone()[0]
|
||||
cur.execute(
|
||||
f"SELECT license_rule, COUNT(*) FROM compliance.{tbl} "
|
||||
f"GROUP BY license_rule ORDER BY license_rule;"
|
||||
)
|
||||
by_rule = {str(r[0]): r[1] for r in cur.fetchall()}
|
||||
cur.execute(
|
||||
f"SELECT COUNT(*) FROM compliance.{tbl} WHERE license_rule IS NULL;"
|
||||
)
|
||||
missing = cur.fetchone()[0]
|
||||
result["tables"][tbl] = {
|
||||
"total": total,
|
||||
"by_rule": by_rule,
|
||||
"missing_license_rule": missing,
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def audit_qdrant(host: str, port: int) -> dict:
|
||||
"""Probe Qdrant collections for license + license_rule payload coverage.
|
||||
|
||||
Samples 500 points per collection and reports how many have neither
|
||||
field populated.
|
||||
"""
|
||||
out: dict = {"collections": {}}
|
||||
base = f"http://{host}:{port}"
|
||||
try:
|
||||
with urllib_request.urlopen(f"{base}/collections", timeout=10) as r:
|
||||
colls = json.loads(r.read()).get("result", {}).get("collections", [])
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
for c in colls:
|
||||
name = c["name"]
|
||||
if "compliance" not in name and "atomic_controls" not in name:
|
||||
continue
|
||||
payload = {"limit": 500, "with_payload": True, "with_vector": False}
|
||||
req = urllib_request.Request(
|
||||
f"{base}/collections/{name}/points/scroll",
|
||||
data=json.dumps(payload).encode(),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
try:
|
||||
with urllib_request.urlopen(req, timeout=15) as r:
|
||||
points = json.loads(r.read()).get("result", {}).get("points", [])
|
||||
except Exception as e:
|
||||
out["collections"][name] = {"error": str(e)}
|
||||
continue
|
||||
sampled = len(points)
|
||||
both_set = 0
|
||||
only_license = 0
|
||||
only_rule = 0
|
||||
neither = 0
|
||||
for p in points:
|
||||
pl = p.get("payload", {}) or {}
|
||||
has_lic = bool(pl.get("license"))
|
||||
has_rule = pl.get("license_rule") is not None
|
||||
if has_lic and has_rule:
|
||||
both_set += 1
|
||||
elif has_lic:
|
||||
only_license += 1
|
||||
elif has_rule:
|
||||
only_rule += 1
|
||||
else:
|
||||
neither += 1
|
||||
out["collections"][name] = {
|
||||
"sampled": sampled,
|
||||
"both_set": both_set,
|
||||
"only_license_field": only_license,
|
||||
"only_license_rule_field": only_rule,
|
||||
"neither_set": neither,
|
||||
"neither_pct": round(neither / sampled * 100, 1) if sampled else 0,
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
try:
|
||||
import psycopg2
|
||||
except ImportError:
|
||||
print("error: psycopg2 not installed (pip install psycopg2-binary)", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
conn = psycopg2.connect(
|
||||
host=args.db_host,
|
||||
port=args.db_port,
|
||||
user=args.db_user,
|
||||
dbname=args.db_name,
|
||||
password=args.db_password or None,
|
||||
)
|
||||
try:
|
||||
registry = audit_registry(conn)
|
||||
controls = audit_atomic_controls(conn)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
qdrant: Optional[dict] = None
|
||||
if args.check_qdrant:
|
||||
qdrant = audit_qdrant(args.qdrant_host, args.qdrant_port)
|
||||
|
||||
result = {"registry": registry, "atomic_controls": controls, "qdrant": qdrant}
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(result, indent=2, default=str))
|
||||
return 0
|
||||
|
||||
print("=" * 60)
|
||||
print(" Audit — License Classification")
|
||||
print("=" * 60)
|
||||
print()
|
||||
print(f"## regulation_registry ({registry['total']} rows)")
|
||||
print(f" By rule: {registry['by_rule']}")
|
||||
print(f" Missing license_type: {registry['missing_license_type']}")
|
||||
print()
|
||||
print("## atomic_controls")
|
||||
for tbl, info in controls.get("tables", {}).items():
|
||||
if info.get("skipped"):
|
||||
print(f" {tbl}: SKIPPED ({info['reason']})")
|
||||
continue
|
||||
print(f" {tbl}: {info['total']} rows")
|
||||
print(f" by_rule={info['by_rule']}")
|
||||
print(f" missing_license_rule={info['missing_license_rule']}")
|
||||
print()
|
||||
if qdrant:
|
||||
print("## qdrant")
|
||||
for name, info in qdrant.get("collections", {}).items():
|
||||
if "error" in info:
|
||||
print(f" {name}: ERROR {info['error']}")
|
||||
continue
|
||||
print(
|
||||
f" {name:30} sampled={info['sampled']:4} "
|
||||
f"both={info['both_set']:4} "
|
||||
f"neither={info['neither_set']:4} ({info['neither_pct']}%)"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,184 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Backfill license_rule on canonical_controls by inheriting from parent.
|
||||
|
||||
Background
|
||||
==========
|
||||
|
||||
Audit (audit_license_classification.py) showed that 279,384 of 314,811 rows
|
||||
in compliance.canonical_controls have NULL license_rule. Drilling in:
|
||||
|
||||
- 261,980 of those (94%) have a parent_control_uuid whose parent already
|
||||
carries a non-NULL license_rule. The pass0b decomposition pipeline did
|
||||
not propagate the rule to its child controls — this is a clear inheritance
|
||||
bug, fixable without any classification decisions.
|
||||
- 16,617 have a parent that itself has no license_rule (transitive case).
|
||||
Inheriting iteratively converges to either rule-set or root-orphan.
|
||||
- 787 have no parent at all (decomposition roots). These need cluster-based
|
||||
manual classification (see Strategy Notes at the bottom of this file).
|
||||
|
||||
This script runs the inheritance fix in three idempotent stages and
|
||||
prints per-stage counts before any write happens.
|
||||
|
||||
Usage::
|
||||
|
||||
# Always dry-run first:
|
||||
python3 scripts/backfill_license_rule.py --db-host 100.80.114.48 \\
|
||||
--db-password breakpilot123 --dry-run
|
||||
|
||||
# If counts look right:
|
||||
python3 scripts/backfill_license_rule.py --db-host 100.80.114.48 \\
|
||||
--db-password breakpilot123 --apply
|
||||
|
||||
The script is safe to rerun — it only touches rows where license_rule
|
||||
IS NULL.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
p = argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument("--db-host", default="100.80.114.48")
|
||||
p.add_argument("--db-port", type=int, default=5432)
|
||||
p.add_argument("--db-user", default="breakpilot")
|
||||
p.add_argument("--db-name", default="breakpilot_db")
|
||||
p.add_argument("--db-password", required=True)
|
||||
g = p.add_mutually_exclusive_group(required=True)
|
||||
g.add_argument("--dry-run", action="store_true")
|
||||
g.add_argument("--apply", action="store_true")
|
||||
p.add_argument("--max-iterations", type=int, default=5,
|
||||
help="Cap on inheritance iterations to avoid loops")
|
||||
return p.parse_args()
|
||||
|
||||
|
||||
# Stage 1: direct parent has license_rule — single UPDATE.
|
||||
# Stage 2: iterative — parent did not have it, but a grandparent does.
|
||||
# We loop until no more rows can be filled or max-iterations.
|
||||
# Stage 3: residual rows with no resolvable parent. Report them clustered
|
||||
# by category/pattern_id so the user can classify by family.
|
||||
|
||||
SQL_REPORT_NULLS = """
|
||||
SET search_path TO compliance, public;
|
||||
SELECT
|
||||
CASE WHEN cc.parent_control_uuid IS NULL THEN 'no_parent'
|
||||
WHEN p.license_rule IS NULL THEN 'parent_null'
|
||||
ELSE 'parent_set' END AS bucket,
|
||||
COUNT(*) AS n
|
||||
FROM canonical_controls cc
|
||||
LEFT JOIN canonical_controls p ON cc.parent_control_uuid = p.id
|
||||
WHERE cc.license_rule IS NULL
|
||||
GROUP BY 1 ORDER BY 2 DESC;
|
||||
"""
|
||||
|
||||
SQL_INHERIT_FROM_PARENT = """
|
||||
SET search_path TO compliance, public;
|
||||
UPDATE canonical_controls cc
|
||||
SET license_rule = p.license_rule, updated_at = NOW()
|
||||
FROM canonical_controls p
|
||||
WHERE cc.parent_control_uuid = p.id
|
||||
AND cc.license_rule IS NULL
|
||||
AND p.license_rule IS NOT NULL;
|
||||
"""
|
||||
|
||||
SQL_REPORT_ORPHAN_CLUSTERS = """
|
||||
SET search_path TO compliance, public;
|
||||
SELECT
|
||||
COALESCE(category, '(null)') AS category,
|
||||
COALESCE(pattern_id, '(null)') AS pattern_id,
|
||||
COALESCE(generation_strategy, '(null)') AS gen,
|
||||
COUNT(*) AS n
|
||||
FROM canonical_controls
|
||||
WHERE license_rule IS NULL AND parent_control_uuid IS NULL
|
||||
GROUP BY 1, 2, 3 ORDER BY n DESC LIMIT 25;
|
||||
"""
|
||||
|
||||
|
||||
def print_bucket(rows, label: str) -> None:
|
||||
print(f"\n## {label}")
|
||||
total = 0
|
||||
for bucket, n in rows:
|
||||
print(f" {bucket:12} {n:>8}")
|
||||
total += n
|
||||
print(f" {'TOTAL':12} {total:>8}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
try:
|
||||
import psycopg2
|
||||
except ImportError:
|
||||
print("error: psycopg2 not installed", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
conn = psycopg2.connect(
|
||||
host=args.db_host, port=args.db_port, user=args.db_user,
|
||||
dbname=args.db_name, password=args.db_password,
|
||||
)
|
||||
conn.autocommit = False
|
||||
cur = conn.cursor()
|
||||
|
||||
print("=" * 60)
|
||||
print(" Backfill — license_rule via parent inheritance")
|
||||
print(f" Mode: {'DRY-RUN' if args.dry_run else 'APPLY'}")
|
||||
print("=" * 60)
|
||||
|
||||
# Initial bucket report
|
||||
cur.execute(SQL_REPORT_NULLS)
|
||||
rows = cur.fetchall()
|
||||
print_bucket(rows, "Initial NULL distribution")
|
||||
|
||||
if args.dry_run:
|
||||
# Print what the FIRST inherit pass would resolve (without writing)
|
||||
cur.execute(
|
||||
"SET search_path TO compliance, public; "
|
||||
"SELECT p.license_rule, COUNT(*) "
|
||||
"FROM canonical_controls cc "
|
||||
"JOIN canonical_controls p ON cc.parent_control_uuid = p.id "
|
||||
"WHERE cc.license_rule IS NULL AND p.license_rule IS NOT NULL "
|
||||
"GROUP BY 1 ORDER BY 1;"
|
||||
)
|
||||
print("\n## First inherit-pass would fill:")
|
||||
for rule, n in cur.fetchall():
|
||||
print(f" rule={rule} {n:>8} rows")
|
||||
|
||||
# Show orphan clusters that would remain
|
||||
cur.execute(SQL_REPORT_ORPHAN_CLUSTERS)
|
||||
print("\n## Orphan clusters (no parent + no rule, top 25):")
|
||||
for cat, pid, gen, n in cur.fetchall():
|
||||
print(f" cat={cat[:20]:20} pat={pid[:20]:20} gen={gen[:20]:20} n={n}")
|
||||
print("\nNo writes performed. Use --apply to execute.")
|
||||
conn.rollback()
|
||||
return 0
|
||||
|
||||
# Apply mode — iterative inheritance
|
||||
total_updated = 0
|
||||
for i in range(1, args.max_iterations + 1):
|
||||
cur.execute(SQL_INHERIT_FROM_PARENT)
|
||||
updated = cur.rowcount
|
||||
total_updated += updated
|
||||
print(f"\n iteration {i}: {updated} rows updated")
|
||||
if updated == 0:
|
||||
break
|
||||
|
||||
conn.commit()
|
||||
print(f"\n✓ Total rows backfilled: {total_updated}")
|
||||
|
||||
# Final bucket report
|
||||
cur.execute(SQL_REPORT_NULLS)
|
||||
print_bucket(cur.fetchall(), "Remaining NULL distribution")
|
||||
|
||||
cur.execute(SQL_REPORT_ORPHAN_CLUSTERS)
|
||||
rows = cur.fetchall()
|
||||
if rows:
|
||||
print("\n## Orphan clusters still need classification:")
|
||||
for cat, pid, gen, n in rows:
|
||||
print(f" cat={cat[:20]:20} pat={pid[:20]:20} gen={gen[:20]:20} n={n}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,203 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Backfill ``license_rule`` payload field into Qdrant atomic_controls_dedup
|
||||
and related compliance collections, sourced from canonical_controls in Postgres.
|
||||
|
||||
The audit (audit_license_classification.py) surfaced that Qdrant collections
|
||||
holding canonical-control vectors (notably ``atomic_controls_dedup``) carry no
|
||||
license_rule payload at all, even though the underlying Postgres table is now
|
||||
fully classified. This script joins the two via ``control_uuid`` and patches the
|
||||
Qdrant payload in batches.
|
||||
|
||||
Usage::
|
||||
|
||||
python3 scripts/backfill_qdrant_license_payload.py \\
|
||||
--pg-host 100.80.114.48 --pg-password breakpilot123 \\
|
||||
--qdrant http://100.80.114.48:6333 \\
|
||||
--collection atomic_controls_dedup \\
|
||||
--dry-run
|
||||
|
||||
# apply
|
||||
python3 scripts/backfill_qdrant_license_payload.py ... --apply
|
||||
|
||||
Notes
|
||||
-----
|
||||
- ``control_uuid`` lives in the payload of atomic_controls_dedup. For other
|
||||
collections that key the canonical control by a different field, override with
|
||||
``--uuid-field``.
|
||||
- Qdrant ``set_payload`` is keyed by point id, not payload field. We resolve
|
||||
UUID → point id by a paginated scroll-and-filter pass, then issue grouped
|
||||
set_payload requests per license_rule (3 batches per collection).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from typing import Iterator
|
||||
from urllib import request as urllib_request
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
p = argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument("--pg-host", default="100.80.114.48")
|
||||
p.add_argument("--pg-port", type=int, default=5432)
|
||||
p.add_argument("--pg-user", default="breakpilot")
|
||||
p.add_argument("--pg-name", default="breakpilot_db")
|
||||
p.add_argument("--pg-password", required=True)
|
||||
p.add_argument("--qdrant", default="http://100.80.114.48:6333")
|
||||
p.add_argument("--qdrant-api-key", default="",
|
||||
help="API key for managed Qdrant (Production)")
|
||||
p.add_argument("--collection", default="atomic_controls_dedup")
|
||||
p.add_argument("--uuid-field", default="control_uuid",
|
||||
help="Payload field used for lookup (control_uuid or regulation_id)")
|
||||
p.add_argument("--lookup", choices=["canonical_controls", "regulation_registry"],
|
||||
default="canonical_controls",
|
||||
help="Postgres table to resolve the lookup against")
|
||||
p.add_argument("--batch-size", type=int, default=500)
|
||||
g = p.add_mutually_exclusive_group(required=True)
|
||||
g.add_argument("--dry-run", action="store_true")
|
||||
g.add_argument("--apply", action="store_true")
|
||||
return p.parse_args()
|
||||
|
||||
|
||||
def fetch_rule_by_uuid(args) -> dict[str, int]:
|
||||
"""Pull lookup-key → license_rule mapping from Postgres.
|
||||
|
||||
Source table is chosen by ``--lookup``:
|
||||
- canonical_controls: id (UUID) → license_rule, for atomic_controls_dedup
|
||||
- regulation_registry: regulation_id → license_rule, for document chunks
|
||||
"""
|
||||
import psycopg2
|
||||
conn = psycopg2.connect(
|
||||
host=args.pg_host, port=args.pg_port, user=args.pg_user,
|
||||
dbname=args.pg_name, password=args.pg_password,
|
||||
)
|
||||
cur = conn.cursor()
|
||||
cur.execute("SET search_path TO compliance, public;")
|
||||
if args.lookup == "regulation_registry":
|
||||
cur.execute(
|
||||
"SELECT regulation_id, license_rule FROM regulation_registry "
|
||||
"WHERE license_rule IS NOT NULL"
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
"SELECT id::text, license_rule FROM canonical_controls "
|
||||
"WHERE license_rule IS NOT NULL"
|
||||
)
|
||||
mapping = {row[0]: int(row[1]) for row in cur.fetchall()}
|
||||
conn.close()
|
||||
return mapping
|
||||
|
||||
|
||||
def _headers(api_key: str = "") -> dict:
|
||||
h = {"Content-Type": "application/json"}
|
||||
if api_key:
|
||||
h["api-key"] = api_key
|
||||
return h
|
||||
|
||||
|
||||
def scroll_collection(qdrant: str, collection: str, uuid_field: str, api_key: str = "") -> Iterator[dict]:
|
||||
"""Yield (point_id, uuid_value, has_rule_already) tuples."""
|
||||
next_offset = None
|
||||
while True:
|
||||
body = {"limit": 1000, "with_payload": True, "with_vector": False}
|
||||
if next_offset is not None:
|
||||
body["offset"] = next_offset
|
||||
req = urllib_request.Request(
|
||||
f"{qdrant}/collections/{collection}/points/scroll",
|
||||
data=json.dumps(body).encode(),
|
||||
headers=_headers(api_key),
|
||||
)
|
||||
with urllib_request.urlopen(req, timeout=60) as r:
|
||||
payload = json.loads(r.read())
|
||||
result = payload.get("result", {})
|
||||
for pt in result.get("points", []):
|
||||
pl = pt.get("payload", {}) or {}
|
||||
yield {
|
||||
"id": pt["id"],
|
||||
"uuid": pl.get(uuid_field),
|
||||
"has_rule": "license_rule" in pl,
|
||||
}
|
||||
next_offset = result.get("next_page_offset")
|
||||
if next_offset is None:
|
||||
break
|
||||
|
||||
|
||||
def set_payload_batch(qdrant: str, collection: str, point_ids: list, rule: int, api_key: str = "") -> int:
|
||||
"""POST set_payload for a batch of point IDs with a single license_rule."""
|
||||
body = {
|
||||
"payload": {"license_rule": rule},
|
||||
"points": point_ids,
|
||||
}
|
||||
req = urllib_request.Request(
|
||||
f"{qdrant}/collections/{collection}/points/payload?wait=true",
|
||||
data=json.dumps(body).encode(),
|
||||
headers=_headers(api_key),
|
||||
method="POST",
|
||||
)
|
||||
with urllib_request.urlopen(req, timeout=120) as r:
|
||||
resp = json.loads(r.read())
|
||||
if resp.get("status") != "ok":
|
||||
raise RuntimeError(f"set_payload failed: {resp}")
|
||||
return len(point_ids)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
print("Loading canonical_controls → license_rule mapping…")
|
||||
rule_by_uuid = fetch_rule_by_uuid(args)
|
||||
print(f" Postgres returned {len(rule_by_uuid)} classified controls")
|
||||
|
||||
print(f"Scrolling Qdrant collection {args.collection!r}…")
|
||||
by_rule: dict[int, list] = {1: [], 2: [], 3: []}
|
||||
points_total = 0
|
||||
points_with_uuid = 0
|
||||
points_already_set = 0
|
||||
points_no_match = 0
|
||||
|
||||
for pt in scroll_collection(args.qdrant, args.collection, args.uuid_field, args.qdrant_api_key):
|
||||
points_total += 1
|
||||
uuid = pt["uuid"]
|
||||
if not uuid:
|
||||
continue
|
||||
points_with_uuid += 1
|
||||
if pt["has_rule"]:
|
||||
points_already_set += 1
|
||||
continue
|
||||
rule = rule_by_uuid.get(uuid)
|
||||
if rule is None:
|
||||
points_no_match += 1
|
||||
continue
|
||||
if rule not in by_rule:
|
||||
continue
|
||||
by_rule[rule].append(pt["id"])
|
||||
|
||||
print(f" total points scanned: {points_total}")
|
||||
print(f" with {args.uuid_field}: {points_with_uuid}")
|
||||
print(f" already had license_rule: {points_already_set}")
|
||||
print(f" uuid not found in Postgres: {points_no_match}")
|
||||
print(f" to set per rule: rule1={len(by_rule[1])} rule2={len(by_rule[2])} rule3={len(by_rule[3])}")
|
||||
|
||||
if args.dry_run:
|
||||
print("\nDRY-RUN: no writes performed. Use --apply to execute.")
|
||||
return 0
|
||||
|
||||
total_written = 0
|
||||
for rule, ids in by_rule.items():
|
||||
if not ids:
|
||||
continue
|
||||
print(f"\nWriting license_rule={rule} to {len(ids)} points (batch {args.batch_size})…")
|
||||
for i in range(0, len(ids), args.batch_size):
|
||||
chunk = ids[i:i + args.batch_size]
|
||||
n = set_payload_batch(args.qdrant, args.collection, chunk, rule, args.qdrant_api_key)
|
||||
total_written += n
|
||||
print(f" batch {i // args.batch_size + 1}: {n} points (cumulative {total_written})")
|
||||
time.sleep(0.05)
|
||||
print(f"\nWrote license_rule on {total_written} Qdrant points in {args.collection}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,400 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Clean-Room MC derivation from BSI QUAIDAL.
|
||||
|
||||
For each QUAIDAL entry in the parsed index, ask a local LLM to produce our own
|
||||
wording for a Master Control / atomic control / mitigation / metric. Reject any
|
||||
output whose 4-gram overlap with the BSI source text exceeds PLAGIARISM_LIMIT.
|
||||
|
||||
We never store the BSI prose; only our own derived wording plus structural
|
||||
references (BSI section ID + URL + commit SHA).
|
||||
|
||||
Usage:
|
||||
# Single entry, prints to stdout for review:
|
||||
python3 control-pipeline/scripts/derive_quaidal_mcs.py --only QKB-01 --dry-run
|
||||
|
||||
# Full run, writes YAML:
|
||||
python3 control-pipeline/scripts/derive_quaidal_mcs.py --ollama-host macmini
|
||||
|
||||
Output: control-pipeline/data/quaidal/{master_controls,atomic_controls,mitigations,metrics}.yaml
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import httpx
|
||||
import yaml
|
||||
except ImportError as e:
|
||||
print(f"ERROR: missing dependency {e.name}. Install with: pip install httpx pyyaml", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
SOURCE_ROOT = REPO_ROOT / "legal-sources" / "bsi-quaidal"
|
||||
INDEX_FILE = REPO_ROOT / "control-pipeline" / "data" / "quaidal" / "quaidal_index.json"
|
||||
OUTPUT_DIR = REPO_ROOT / "control-pipeline" / "data" / "quaidal"
|
||||
|
||||
PLAGIARISM_LIMIT = 0.20 # max share of 4-grams that may appear in BSI source
|
||||
N_GRAM = 4
|
||||
MAX_RETRIES = 3
|
||||
|
||||
DEFAULT_OLLAMA_URL = "http://macmini:11434"
|
||||
OLLAMA_MODEL = "qwen3.5:35b-a3b"
|
||||
QUAIDAL_REPO_URL = "https://github.com/BSI-Bund/QUAIDAL"
|
||||
|
||||
KIND_TO_PROMPT_ROLE = {
|
||||
"criterion": "Master Control",
|
||||
"building_block": "atomarer technischer Control",
|
||||
"measure": "Schutzmaßnahme",
|
||||
"metric": "messbarer Qualitäts-Indikator",
|
||||
}
|
||||
|
||||
KIND_TO_OUTPUT_FILE = {
|
||||
"criterion": "master_controls.yaml",
|
||||
"building_block": "atomic_controls.yaml",
|
||||
"measure": "mitigations.yaml",
|
||||
"metric": "metrics.yaml",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Source-side extraction (kept in memory, never written to disk)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
FRONTMATTER_RE = re.compile(r"^---\s*\n.*?\n---\s*\n", re.DOTALL)
|
||||
SECTION_RE = re.compile(r"^###?\s+(.+?)\s*$", re.MULTILINE)
|
||||
|
||||
|
||||
def load_source_extract(rel_path: str) -> dict:
|
||||
"""Load BSI source text for ONE entry. Used only for prompt + plagiarism check."""
|
||||
path = SOURCE_ROOT / rel_path
|
||||
text = path.read_text(encoding="utf-8")
|
||||
|
||||
# Strip frontmatter; capture shortdesc separately for the prompt.
|
||||
fm_match = re.match(r"^---\s*\n(.*?)\n---\s*\n", text, re.DOTALL)
|
||||
shortdesc = ""
|
||||
if fm_match:
|
||||
for line in fm_match.group(1).splitlines():
|
||||
if line.lower().startswith("shortdesc:"):
|
||||
shortdesc = line.split(":", 1)[1].strip()
|
||||
break
|
||||
body = FRONTMATTER_RE.sub("", text, count=1)
|
||||
|
||||
# Pull the first 1-2 paragraphs under "Beschreibung" (or whole body if none)
|
||||
desc_match = re.search(r"###?\s+Beschreibung\s*\n+(.+?)(?:\n###?\s|\Z)", body, re.DOTALL)
|
||||
description_excerpt = desc_match.group(1).strip() if desc_match else body[:1500].strip()
|
||||
paragraphs = [p.strip() for p in description_excerpt.split("\n\n") if p.strip()]
|
||||
description_excerpt = "\n\n".join(paragraphs[:2])
|
||||
|
||||
return {
|
||||
"shortdesc": shortdesc,
|
||||
"description_excerpt": description_excerpt,
|
||||
"full_body": body,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plagiarism gate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
WORD_RE = re.compile(r"\b[\wäöüÄÖÜß]+\b", re.UNICODE)
|
||||
|
||||
|
||||
def _tokenize(text: str) -> list[str]:
|
||||
return [w.lower() for w in WORD_RE.findall(text)]
|
||||
|
||||
|
||||
def ngram_overlap(produced: str, source: str, n: int = N_GRAM) -> float:
|
||||
"""Share of produced n-grams that also appear in source."""
|
||||
p_tokens = _tokenize(produced)
|
||||
s_tokens = _tokenize(source)
|
||||
if len(p_tokens) < n:
|
||||
return 0.0
|
||||
s_grams = {tuple(s_tokens[i : i + n]) for i in range(len(s_tokens) - n + 1)}
|
||||
if not s_grams:
|
||||
return 0.0
|
||||
p_grams = [tuple(p_tokens[i : i + n]) for i in range(len(p_tokens) - n + 1)]
|
||||
hits = sum(1 for g in p_grams if g in s_grams)
|
||||
return hits / len(p_grams)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LLM prompt + call
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
PROMPT_TEMPLATE = """Du bist Compliance-Engineer bei BreakPilot. Schreibe eine eigenständige Anforderung im Stil einer technischen Kontroll-Spezifikation.
|
||||
|
||||
Quelle: BSI QUAIDAL Sektion {entry_id} ("{title_de}"). Die Quelle steht unter unklarer Lizenz (BSI-Veröffentlichung, § 5 UrhG anwendbar) — wir dürfen die Idee aufgreifen, aber NICHT abschreiben.
|
||||
|
||||
Aufgabe: Formuliere eine eigenständige Anforderung im Stil eines {role}. Anforderungen:
|
||||
- Eigene Formulierung in deutscher Sprache. Kein Satz darf aus der Quelle übernommen werden, auch nicht teilweise. Synonyme verwenden, Satzbau ändern, Inhalt strukturell anders aufbauen.
|
||||
- 2-4 Sätze (max 80 Wörter).
|
||||
- Sprachstil: nüchtern, technisch, normativ ("muss", "ist sicherzustellen", "ist zu prüfen").
|
||||
- Bezug auf KI-Trainingsdaten oder KI-Datenqualität, je nach Quelle.
|
||||
- Nicht die wörtlichen BSI-Beispiele kopieren.
|
||||
|
||||
Quellauszug (NUR zur Orientierung, NICHT abschreiben):
|
||||
---
|
||||
shortdesc: {shortdesc}
|
||||
|
||||
{description_excerpt}
|
||||
---
|
||||
|
||||
Antwort: Liefere AUSSCHLIESSLICH die fertige Beschreibung als reinen Text — kein JSON, keine Überschriften, keine Anführungszeichen, keine Quellenangabe."""
|
||||
|
||||
|
||||
def call_ollama(prompt: str, ollama_url: str, model: str, retries: int = 2) -> str:
|
||||
last_err = None
|
||||
for attempt in range(retries + 1):
|
||||
try:
|
||||
resp = httpx.post(
|
||||
f"{ollama_url}/api/chat",
|
||||
json={
|
||||
"model": model,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"stream": False,
|
||||
"options": {"temperature": 0.4},
|
||||
"think": False,
|
||||
},
|
||||
timeout=180.0,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()["message"]["content"].strip()
|
||||
except (httpx.HTTPError, KeyError, ValueError) as e:
|
||||
last_err = e
|
||||
if attempt < retries:
|
||||
time.sleep(2 ** attempt)
|
||||
raise RuntimeError(f"Ollama call failed after {retries+1} attempts: {last_err}")
|
||||
|
||||
|
||||
def strip_llm_artifacts(text: str) -> str:
|
||||
"""Clean leading/trailing markdown and quotes from LLM output."""
|
||||
text = text.strip()
|
||||
# Strip surrounding code fences
|
||||
if text.startswith("```"):
|
||||
text = re.sub(r"^```[a-zA-Z]*\n?", "", text)
|
||||
text = re.sub(r"\n?```\s*$", "", text)
|
||||
# Strip surrounding quotes
|
||||
text = text.strip('"„"”„')
|
||||
# Drop a leading "Beschreibung:" or similar label
|
||||
text = re.sub(r"^(Beschreibung|Description|Anforderung|Control):\s*", "", text, flags=re.IGNORECASE)
|
||||
return text.strip()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Derivation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class DerivedControl:
|
||||
derived_id: str
|
||||
source_id: str
|
||||
kind: str
|
||||
canonical_name: str
|
||||
description: str
|
||||
plagiarism_score: float
|
||||
related_quaidal_ids: list[str]
|
||||
external_refs: list[dict]
|
||||
source: dict
|
||||
|
||||
|
||||
_ASCII_FOLD = str.maketrans({"ä": "ae", "ö": "oe", "ü": "ue", "Ä": "ae", "Ö": "oe", "Ü": "ue", "ß": "ss"})
|
||||
|
||||
|
||||
def slug(text: str) -> str:
|
||||
text = text.translate(_ASCII_FOLD).lower()
|
||||
text = re.sub(r"[^a-z0-9]+", "-", text)
|
||||
return text.strip("-")
|
||||
|
||||
|
||||
def derived_id_for(entry: dict) -> str:
|
||||
prefix = {
|
||||
"criterion": "MC-AI-DATA",
|
||||
"building_block": "AC-AI-DATA",
|
||||
"measure": "MIT-AI-DATA",
|
||||
"metric": "MET-AI-DATA",
|
||||
}.get(entry["kind"], "X-AI-DATA")
|
||||
title = entry["title_de"]
|
||||
title = re.sub(r"^\s*(QKB|QB|MA|QM)-\d+[a-zA-Z]?\s*", "", title)
|
||||
return f"{prefix}-{entry['id']}-{slug(title)[:40]}".rstrip("-")
|
||||
|
||||
|
||||
def derive_one(entry: dict, source_extract: dict, ollama_url: str, model: str, *, verbose: bool = False) -> DerivedControl:
|
||||
role = KIND_TO_PROMPT_ROLE.get(entry["kind"], "Control")
|
||||
prompt = PROMPT_TEMPLATE.format(
|
||||
entry_id=entry["id"],
|
||||
title_de=entry["title_de"],
|
||||
role=role,
|
||||
shortdesc=source_extract["shortdesc"] or "(keiner)",
|
||||
description_excerpt=source_extract["description_excerpt"] or "(keine Beschreibung)",
|
||||
)
|
||||
|
||||
source_corpus = "\n\n".join(filter(None, [source_extract["shortdesc"], source_extract["description_excerpt"]]))
|
||||
|
||||
best: tuple[str, float] | None = None
|
||||
for attempt in range(1, MAX_RETRIES + 1):
|
||||
output = call_ollama(prompt, ollama_url, model)
|
||||
output = strip_llm_artifacts(output)
|
||||
score = ngram_overlap(output, source_corpus)
|
||||
if verbose:
|
||||
print(f" attempt {attempt}: overlap={score:.2%} len={len(output)}", file=sys.stderr)
|
||||
if score < PLAGIARISM_LIMIT:
|
||||
best = (output, score)
|
||||
break
|
||||
if best is None or score < best[1]:
|
||||
best = (output, score)
|
||||
# Strengthen the next prompt by appending a reject notice
|
||||
prompt += f"\n\n(Vorheriger Versuch hatte {score:.0%} Wortdeckung mit der Quelle. Verwende völlig andere Begriffe und Satzstruktur.)"
|
||||
|
||||
if best is None:
|
||||
raise RuntimeError(f"Could not derive {entry['id']}: no output")
|
||||
output, score = best
|
||||
if score >= PLAGIARISM_LIMIT:
|
||||
raise RuntimeError(
|
||||
f"Plagiarism gate failed for {entry['id']}: best overlap {score:.2%} >= limit {PLAGIARISM_LIMIT:.0%}.\n"
|
||||
f"Output:\n{output}"
|
||||
)
|
||||
|
||||
title_de_clean = re.sub(r"^\s*(QKB|QB|MA|QM)-\d+[a-zA-Z]?\s*", "", entry["title_de"]).strip()
|
||||
return DerivedControl(
|
||||
derived_id=derived_id_for(entry),
|
||||
source_id=entry["id"],
|
||||
kind=entry["kind"],
|
||||
canonical_name=title_de_clean or entry["title_de"],
|
||||
description=output,
|
||||
plagiarism_score=round(score, 4),
|
||||
related_quaidal_ids=entry["referenced_ids"],
|
||||
external_refs=entry["external_refs"],
|
||||
source={
|
||||
"framework": "BSI QUAIDAL",
|
||||
"section": entry["id"],
|
||||
"title_original_de": entry["title_de"],
|
||||
"url": f"{QUAIDAL_REPO_URL}/blob/main/{entry['source_path'].replace(' ', '%20')}",
|
||||
"commit_sha": None, # filled in by main()
|
||||
"license_note": "§ 5 UrhG anwendbar; share:true im Frontmatter; Clean-Room-Ableitung.",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Output writers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def control_to_dict(c: DerivedControl) -> dict:
|
||||
d = {
|
||||
"id": c.derived_id,
|
||||
"canonical_name": c.canonical_name,
|
||||
"description": c.description,
|
||||
"kind": c.kind,
|
||||
"regulation_anchor": "EU AI Act Art. 10 (Datenqualität für Hochrisiko-KI)",
|
||||
"related_quaidal_ids": c.related_quaidal_ids,
|
||||
"external_refs": c.external_refs,
|
||||
"source": c.source,
|
||||
"plagiarism_score_at_generation": c.plagiarism_score,
|
||||
}
|
||||
return d
|
||||
|
||||
|
||||
def write_yaml_per_kind(controls: list[DerivedControl], commit_sha: str | None) -> dict[str, Path]:
|
||||
out: dict[str, list[dict]] = {}
|
||||
for c in controls:
|
||||
c.source["commit_sha"] = commit_sha
|
||||
fname = KIND_TO_OUTPUT_FILE.get(c.kind, "other.yaml")
|
||||
out.setdefault(fname, []).append(control_to_dict(c))
|
||||
|
||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
written: dict[str, Path] = {}
|
||||
for fname, items in out.items():
|
||||
path = OUTPUT_DIR / fname
|
||||
payload = {
|
||||
"source": "Derived from BSI QUAIDAL (Clean-Room)",
|
||||
"source_url": QUAIDAL_REPO_URL,
|
||||
"commit_sha": commit_sha,
|
||||
"plagiarism_limit_4gram": PLAGIARISM_LIMIT,
|
||||
"generated_by_model": OLLAMA_MODEL,
|
||||
"controls": items,
|
||||
}
|
||||
path.write_text(yaml.safe_dump(payload, allow_unicode=True, sort_keys=False), encoding="utf-8")
|
||||
written[fname] = path
|
||||
return written
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("--only", help="Derive only this QUAIDAL ID (e.g. QKB-01)")
|
||||
ap.add_argument("--kind", help="Derive only entries of this kind (criterion/building_block/measure/metric)")
|
||||
ap.add_argument("--limit", type=int, help="Process at most N entries")
|
||||
ap.add_argument("--dry-run", action="store_true", help="Print derived controls instead of writing YAML")
|
||||
ap.add_argument("--ollama-host", default="macmini", help="Ollama host (default: macmini)")
|
||||
ap.add_argument("--model", default=OLLAMA_MODEL)
|
||||
ap.add_argument("--verbose", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
if not INDEX_FILE.exists():
|
||||
print(f"ERROR: missing index. Run ingest_bsi_quaidal.py first ({INDEX_FILE})", file=sys.stderr)
|
||||
return 2
|
||||
index = json.loads(INDEX_FILE.read_text(encoding="utf-8"))
|
||||
entries = index["entries"]
|
||||
if args.only:
|
||||
entries = [e for e in entries if e["id"].upper() == args.only.upper()]
|
||||
if args.kind:
|
||||
entries = [e for e in entries if e["kind"] == args.kind]
|
||||
if args.limit:
|
||||
entries = entries[: args.limit]
|
||||
|
||||
if not entries:
|
||||
print("No entries match the filter.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
ollama_url = args.ollama_host if "://" in args.ollama_host else f"http://{args.ollama_host}:11434"
|
||||
print(f"Derivation: {len(entries)} entries, model={args.model}, ollama={ollama_url}, limit={PLAGIARISM_LIMIT:.0%}", file=sys.stderr)
|
||||
|
||||
derived: list[DerivedControl] = []
|
||||
failed: list[tuple[str, str]] = []
|
||||
for i, entry in enumerate(entries, 1):
|
||||
if args.verbose:
|
||||
print(f"[{i}/{len(entries)}] {entry['id']} ({entry['kind']}): {entry['title_de']}", file=sys.stderr)
|
||||
try:
|
||||
extract = load_source_extract(entry["source_path"])
|
||||
ctrl = derive_one(entry, extract, ollama_url, args.model, verbose=args.verbose)
|
||||
derived.append(ctrl)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
failed.append((entry["id"], str(exc)))
|
||||
print(f" FAILED {entry['id']}: {exc}", file=sys.stderr)
|
||||
|
||||
print(f"\nDerived: {len(derived)} | Failed: {len(failed)}", file=sys.stderr)
|
||||
|
||||
if args.dry_run:
|
||||
for c in derived:
|
||||
c.source["commit_sha"] = index.get("commit_sha")
|
||||
print(yaml.safe_dump(control_to_dict(c), allow_unicode=True, sort_keys=False))
|
||||
print("---")
|
||||
return 0 if not failed else 1
|
||||
|
||||
written = write_yaml_per_kind(derived, index.get("commit_sha"))
|
||||
for fname, path in written.items():
|
||||
print(f"Wrote {path.relative_to(REPO_ROOT)} ({sum(1 for c in derived if KIND_TO_OUTPUT_FILE[c.kind] == fname)} entries)", file=sys.stderr)
|
||||
|
||||
if failed:
|
||||
print("\nFailures:", file=sys.stderr)
|
||||
for fid, msg in failed:
|
||||
print(f" - {fid}: {msg.splitlines()[0]}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,242 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Parse BSI QUAIDAL Markdown catalog into a structural index.
|
||||
|
||||
Clean-Room principle: this script does NOT persist any QUAIDAL prose to disk.
|
||||
It only extracts non-protectable structural facts (IDs, type, file paths,
|
||||
cross-references to other QUAIDAL entries, references to external norms).
|
||||
|
||||
The derivation step (derive_quaidal_mcs.py) reads the index plus the original
|
||||
.md files from the gitignored clone and asks the LLM to produce our own
|
||||
wordings, never copying the BSI prose into our own controls/database.
|
||||
|
||||
Input: legal-sources/bsi-quaidal/0000_Markdown/**/*.md (gitignored clone)
|
||||
Output: control-pipeline/data/quaidal/quaidal_index.json (structural only)
|
||||
|
||||
Usage:
|
||||
python3 control-pipeline/scripts/ingest_bsi_quaidal.py
|
||||
python3 control-pipeline/scripts/ingest_bsi_quaidal.py --check # validate only
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
print("ERROR: PyYAML missing. Install with: pip install pyyaml", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
SOURCE_ROOT = REPO_ROOT / "legal-sources" / "bsi-quaidal"
|
||||
MARKDOWN_ROOT = SOURCE_ROOT / "0000_Markdown"
|
||||
OUTPUT_DIR = REPO_ROOT / "control-pipeline" / "data" / "quaidal"
|
||||
OUTPUT_FILE = OUTPUT_DIR / "quaidal_index.json"
|
||||
|
||||
# Map folder name -> our internal kind. Sub-folders inside the Methoden tree
|
||||
# (e.g. "QM-10_Dimension Reduction") are treated as method variants of their
|
||||
# parent QM.
|
||||
KIND_BY_PARENT_DIR = {
|
||||
"0000_Qualitätskriterien": "criterion", # QKB → Master Control candidates
|
||||
"0001_Qualitätsbausteine": "building_block", # QB → atomic controls
|
||||
"0002_Maßnahmen": "measure", # M → mitigations
|
||||
"0003_Qualitätsmetriken_methoden": "metric", # QM → runtime check / metric
|
||||
"0002_Referenz-Matrizen": "matrix", # cross-walk matrix
|
||||
"9998_CustomTemplates": "template",
|
||||
}
|
||||
|
||||
FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n", re.DOTALL)
|
||||
ID_RE = re.compile(r"\b((?:QKB|QB|MA|QM)-\d+[a-zA-Z]?)", re.IGNORECASE)
|
||||
|
||||
|
||||
@dataclass
|
||||
class IndexEntry:
|
||||
id: str # Canonical ID: QKB-01, QB-03, M-12, QM-07
|
||||
kind: str # criterion / building_block / measure / metric / matrix / template
|
||||
title_de: str
|
||||
title_en: str
|
||||
source_path: str # relative to SOURCE_ROOT
|
||||
referenced_ids: list[str] = field(default_factory=list) # other QUAIDAL IDs linked in this file
|
||||
external_refs: list[dict] = field(default_factory=list) # {framework, citation, ref_id}
|
||||
tags: list[str] = field(default_factory=list)
|
||||
share: bool | None = None
|
||||
|
||||
|
||||
def parse_frontmatter(text: str) -> dict:
|
||||
m = FRONTMATTER_RE.match(text)
|
||||
if not m:
|
||||
return {}
|
||||
try:
|
||||
return yaml.safe_load(m.group(1)) or {}
|
||||
except yaml.YAMLError:
|
||||
return {}
|
||||
|
||||
|
||||
def canonical_id(raw_id: str | list | None, filename: str) -> str | None:
|
||||
"""QUAIDAL files sometimes list multiple IDs or odd casing — normalise."""
|
||||
candidates: list[str] = []
|
||||
if isinstance(raw_id, list):
|
||||
candidates.extend(str(x) for x in raw_id)
|
||||
elif isinstance(raw_id, str):
|
||||
candidates.append(raw_id)
|
||||
# Fallback: derive from filename
|
||||
candidates.append(filename)
|
||||
for c in candidates:
|
||||
m = ID_RE.search(c)
|
||||
if m:
|
||||
return m.group(1).upper().replace(" ", "-")
|
||||
return None
|
||||
|
||||
|
||||
def determine_kind(path: Path) -> str:
|
||||
for parent in path.parents:
|
||||
if parent.name in KIND_BY_PARENT_DIR:
|
||||
return KIND_BY_PARENT_DIR[parent.name]
|
||||
return "unknown"
|
||||
|
||||
|
||||
def collect_referenced_ids(body: str, own_id: str) -> list[str]:
|
||||
found = {m.group(1).upper() for m in ID_RE.finditer(body)}
|
||||
found.discard(own_id)
|
||||
return sorted(found)
|
||||
|
||||
|
||||
REF_FRAMEWORKS = [
|
||||
("AI Act", ["AI-Act", "AI Act", "Verordnung (EU) 2024/1689", "KI-VO"]),
|
||||
("EU GDPR", ["DSGVO", "Verordnung (EU) 2016/679", "GDPR"]),
|
||||
("ISO/IEC 25012", ["ISO/IEC 25012", "ISO 25012"]),
|
||||
("ISO/IEC 25024", ["ISO/IEC 25024", "ISO 25024"]),
|
||||
("ISO/IEC 23894", ["ISO/IEC 23894", "ISO 23894"]),
|
||||
("ISO/IEC 42001", ["ISO/IEC 42001", "ISO 42001"]),
|
||||
("NIST AI RMF", ["NIST AI RMF", "AI Risk Management Framework"]),
|
||||
("BSI Grundschutz", ["IT-Grundschutz", "Grundschutz"]),
|
||||
("BSI AIC4", ["AIC4", "AI Cloud Service Compliance Criteria"]),
|
||||
]
|
||||
|
||||
|
||||
def detect_external_refs(body: str) -> list[dict]:
|
||||
refs: list[dict] = []
|
||||
seen: set[tuple[str, str]] = set()
|
||||
# Section "Referenzen" tables — pick up first column ref-id and first
|
||||
# textual hit of the framework. We do NOT store the BSI "Kurzbeschr."
|
||||
# column to avoid copying their prose.
|
||||
for line in body.splitlines():
|
||||
for framework, patterns in REF_FRAMEWORKS:
|
||||
for pat in patterns:
|
||||
if pat.lower() in line.lower():
|
||||
# Try to grab an article/section nearby (e.g. "Artikel 10")
|
||||
art = re.search(r"(Artikel|Art\.?|Section|§)\s*([0-9]+[a-z]?)", line, re.IGNORECASE)
|
||||
citation = f"{art.group(1)} {art.group(2)}" if art else None
|
||||
key = (framework, citation or "")
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
refs.append({"framework": framework, "citation": citation})
|
||||
break
|
||||
return refs
|
||||
|
||||
|
||||
def parse_file(path: Path) -> IndexEntry | None:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
fm = parse_frontmatter(text)
|
||||
body = text[text.find("---", 3) + 3 :] if text.startswith("---") else text
|
||||
|
||||
own_id = canonical_id(fm.get("ID"), path.stem)
|
||||
if not own_id:
|
||||
return None
|
||||
|
||||
title_de = str(fm.get("TitleGer") or fm.get("Title") or path.stem).strip()
|
||||
title_en = str(fm.get("Title") or "").strip()
|
||||
tags_raw = fm.get("tags") or []
|
||||
if isinstance(tags_raw, str):
|
||||
tags_raw = [tags_raw]
|
||||
tags = [str(t).strip() for t in tags_raw if t]
|
||||
|
||||
share_val = fm.get("share")
|
||||
share = bool(share_val) if share_val is not None else None
|
||||
|
||||
return IndexEntry(
|
||||
id=own_id,
|
||||
kind=determine_kind(path),
|
||||
title_de=title_de,
|
||||
title_en=title_en,
|
||||
source_path=str(path.relative_to(SOURCE_ROOT)),
|
||||
referenced_ids=collect_referenced_ids(body, own_id),
|
||||
external_refs=detect_external_refs(body),
|
||||
tags=tags,
|
||||
share=share,
|
||||
)
|
||||
|
||||
|
||||
def get_commit_sha() -> str | None:
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["git", "-C", str(SOURCE_ROOT), "rev-parse", "HEAD"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
return out.stdout.strip()
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
return None
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("--check", action="store_true", help="Parse + validate, do not write output")
|
||||
args = ap.parse_args()
|
||||
|
||||
if not MARKDOWN_ROOT.exists():
|
||||
print(f"ERROR: clone not found at {SOURCE_ROOT}", file=sys.stderr)
|
||||
print("Run: git clone --depth=1 https://github.com/BSI-Bund/QUAIDAL.git legal-sources/bsi-quaidal", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
entries: list[IndexEntry] = []
|
||||
skipped: list[Path] = []
|
||||
for path in sorted(MARKDOWN_ROOT.rglob("*.md")):
|
||||
entry = parse_file(path)
|
||||
if entry is None:
|
||||
skipped.append(path)
|
||||
continue
|
||||
entries.append(entry)
|
||||
|
||||
by_kind: dict[str, int] = {}
|
||||
for e in entries:
|
||||
by_kind[e.kind] = by_kind.get(e.kind, 0) + 1
|
||||
|
||||
print(f"Parsed {len(entries)} entries (skipped {len(skipped)} without ID):")
|
||||
for kind, count in sorted(by_kind.items()):
|
||||
print(f" {kind:18s} {count}")
|
||||
|
||||
if args.check:
|
||||
return 0
|
||||
|
||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
payload = {
|
||||
"source": "BSI QUAIDAL",
|
||||
"source_url": "https://github.com/BSI-Bund/QUAIDAL",
|
||||
"commit_sha": get_commit_sha(),
|
||||
"license_note": (
|
||||
"BSI-Veroeffentlichung. Repo enthaelt keine SPDX-Lizenzdatei. "
|
||||
"Frontmatter share:true. Veroeffentlichung durch Bundesbehoerde, "
|
||||
"§ 5 UrhG (amtliche Werke) anwendbar. BSI hat 05/2026 die Annahme "
|
||||
"CC-BY-SA-4.0 in unserer Anfrage nicht widersprochen, aber auch "
|
||||
"nicht aktiv bestaetigt. Wir derivieren Clean-Room (eigene "
|
||||
"Formulierungen, nur Referenz auf BSI QUAIDAL Sektion)."
|
||||
),
|
||||
"entries": [asdict(e) for e in entries],
|
||||
}
|
||||
OUTPUT_FILE.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(f"\nWrote index: {OUTPUT_FILE.relative_to(REPO_ROOT)}")
|
||||
print(f"Commit SHA: {payload['commit_sha']}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* POST /api/scan/start
|
||||
* Proxy to compliance backend /api/compliance/agent/saving-scan/start.
|
||||
*
|
||||
* Body: { url: string; email: string; consent?: boolean }
|
||||
*
|
||||
* Server-side proxy avoids cross-origin POST from breakpilot.ai to
|
||||
* api-dev.breakpilot.ai — same-origin from the browser, secure egress
|
||||
* from the Next.js server. Backend handles rate-limit + TDM + lead-DB.
|
||||
*/
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL =
|
||||
process.env.COMPLIANCE_BACKEND_URL || 'https://api-dev.breakpilot.ai'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
let body: unknown
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: 'Body muss JSON sein' }, { status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${BACKEND_URL}/api/compliance/agent/saving-scan/start`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(20000),
|
||||
},
|
||||
)
|
||||
const data = await res.json().catch(() => ({}))
|
||||
return NextResponse.json(data, { status: res.status })
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: 'Backend nicht erreichbar' }, { status: 503 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* GET /api/scan/status/<checkId>
|
||||
* Proxy to compliance backend /api/compliance/agent/compliance-check/<id>.
|
||||
*
|
||||
* Polled every ~5s by the savings-scan page until status==completed/failed.
|
||||
*/
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL =
|
||||
process.env.COMPLIANCE_BACKEND_URL || 'https://api-dev.breakpilot.ai'
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ checkId: string }> },
|
||||
) {
|
||||
const { checkId } = await params
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${BACKEND_URL}/api/compliance/agent/compliance-check/${checkId}`,
|
||||
{ signal: AbortSignal.timeout(15000) },
|
||||
)
|
||||
const data = await res.json().catch(() => ({}))
|
||||
return NextResponse.json(data, { status: res.status })
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: 'Backend nicht erreichbar' }, { status: 503 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,27 @@ export default function ImpressumPage() {
|
||||
Unsere E-Mail-Adresse finden Sie oben im Impressum.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white mb-2">Quellen und Lizenzen der Compliance-Inhalte</h2>
|
||||
<p>
|
||||
Die BreakPilot Compliance-Plattform stuetzt sich auf rund 315.000 klassifizierte
|
||||
Controls aus oeffentlichen Quellen: EU-Recht (EUR-Lex), deutsches und oesterreichisches
|
||||
Bundesrecht, US Federal Code (OSHA, NIST), Behoerden-Leitfaeden (ENISA, EDPB, BAuA),
|
||||
freie Sicherheits-Frameworks unter CC-BY-SA (OWASP-Familie, OECD AI Principles) und
|
||||
eigene Texte. Jeder Control traegt eine deterministische Lizenzregel (R1 woertlich, R2
|
||||
mit Attribution, R3 nur Identifier-Verweis), die das Render-Verhalten in Berichten,
|
||||
PDF-Exports und Frontend steuert. Die vollstaendige Quellenliste mit Aufschluesselung
|
||||
pro Lizenzklasse ist im SDK unter <code className="text-white/80">/sdk/licenses</code>
|
||||
eingesehen. Pflicht-Attributionen fuer R2-Quellen erscheinen automatisch im
|
||||
Quellen-Footer jedes generierten Berichts.
|
||||
</p>
|
||||
<p className="mt-2 text-xs">
|
||||
Hinweis: Dieser Pauschalvermerk ersetzt nicht die werknahe Attribution. Jede
|
||||
Berichts- oder Frontend-Ausgabe nennt die konkret verwendeten Quellen direkt am
|
||||
Werk (Auto-Footer in PDFs, Inline-Citation im Frontend).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import Footer from '@/components/layout/Footer'
|
||||
import ChatFAB from '@/components/layout/ChatFAB'
|
||||
import HeroSection from '@/components/sections/HeroSection'
|
||||
import ProblemFlowSection from '@/components/sections/ProblemFlowSection'
|
||||
import SavingsSection from '@/components/sections/SavingsSection'
|
||||
import UseCaseCards from '@/components/sections/UseCaseCards'
|
||||
import TrustBar from '@/components/sections/TrustBar'
|
||||
|
||||
@@ -13,6 +14,7 @@ export default function HomePage() {
|
||||
<main>
|
||||
<HeroSection />
|
||||
<ProblemFlowSection />
|
||||
<SavingsSection />
|
||||
<UseCaseCards />
|
||||
<TrustBar />
|
||||
</main>
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import Navbar from '@/components/layout/Navbar'
|
||||
import Footer from '@/components/layout/Footer'
|
||||
import ChatFAB from '@/components/layout/ChatFAB'
|
||||
import PageHeader from '@/components/ui/PageHeader'
|
||||
import GlassCard from '@/components/ui/GlassCard'
|
||||
import FadeInView from '@/components/ui/FadeInView'
|
||||
import { Database, Layers, Calculator, AlertTriangle, Globe, Cookie } from 'lucide-react'
|
||||
|
||||
export default function SavingsMethodikPage() {
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
<main>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<PageHeader
|
||||
tag="METHODIK"
|
||||
title="Wie Cookies"
|
||||
titleHighlight="Marketing-Budgets entlarven"
|
||||
subtitle="4-Stufen-Analyse: vom rohen Cookie-Footprint zur fundierten Saving-Schaetzung. Jede Stufe nachvollziehbar, jede Zahl mit Quelle, jede Annahme transparent."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 4 Stufen */}
|
||||
<section className="py-12 sm:py-16">
|
||||
<div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 space-y-8">
|
||||
<FadeInView>
|
||||
<GlassCard>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-emerald-500/10 flex items-center justify-center flex-shrink-0">
|
||||
<Cookie className="w-6 h-6 text-emerald-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="mono-label text-emerald-400 mb-1">STUFE 1</div>
|
||||
<h3 className="text-xl font-bold mb-2">Cookie-Footprint extrahieren</h3>
|
||||
<p className="text-sm text-white/60 mb-3">
|
||||
Playwright laedt die Webseite vollstaendig (inkl. JavaScript-Rendering)
|
||||
und erfasst jeden gesetzten Cookie + jeden CMP-Payload
|
||||
(ePaaS, OneTrust, Usercentrics, Cookiebot, Didomi, TrustArc).
|
||||
</p>
|
||||
<ul className="text-sm text-white/50 space-y-1">
|
||||
<li>• Cookie-Namen, Werte, Domains, Lifetimes</li>
|
||||
<li>• IAB TCF v2.2 Vendor-Liste auswerten (Vendor-IDs zur eindeutigen Zuordnung)</li>
|
||||
<li>• Drittanbieter-Quote pro Cookie</li>
|
||||
<li>• Premium-Feature-Cookies erkennen (z.B. <code className="text-emerald-300">s_target_qa</code> = Adobe Target Enterprise)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</FadeInView>
|
||||
|
||||
<FadeInView delay={0.1}>
|
||||
<GlassCard>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-emerald-500/10 flex items-center justify-center flex-shrink-0">
|
||||
<Database className="w-6 h-6 text-emerald-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="mono-label text-emerald-400 mb-1">STUFE 2</div>
|
||||
<h3 className="text-xl font-bold mb-2">Wissens-Datenbank-Abgleich</h3>
|
||||
<p className="text-sm text-white/60 mb-3">
|
||||
Jeder Cookie wird gegen unsere kuratierte Wissens-DB mit derzeit
|
||||
~50 Top-Vendors abgeglichen. Pro Cookie wissen wir:
|
||||
</p>
|
||||
<ul className="text-sm text-white/50 space-y-1">
|
||||
<li>• Setzender Anbieter + Sitzland</li>
|
||||
<li>• Exakter funktionaler Zweck (nicht nur Kategorie)</li>
|
||||
<li>• Welche Datenfelder gesammelt werden (Client-ID, IP, etc.)</li>
|
||||
<li>• Re-Identifikations-Risiko (low/medium/high)</li>
|
||||
<li>• §25(2) TDDDG technische Notwendigkeit</li>
|
||||
<li>• Schrems-II-Status + relevante EuGH-/CNIL-Urteile</li>
|
||||
<li>• Konkreter EU-Alternativ-Cookie + EU-Alternativ-Vendor</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</FadeInView>
|
||||
|
||||
<FadeInView delay={0.2}>
|
||||
<GlassCard>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-emerald-500/10 flex items-center justify-center flex-shrink-0">
|
||||
<Layers className="w-6 h-6 text-emerald-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="mono-label text-emerald-400 mb-1">STUFE 3</div>
|
||||
<h3 className="text-xl font-bold mb-2">Tier-Inferenz + Funktionale Kategorisierung</h3>
|
||||
<p className="text-sm text-white/60 mb-3">
|
||||
Pro Vendor leiten wir das Pricing-Tier aus dem Cookie-Footprint ab:
|
||||
</p>
|
||||
<ul className="text-sm text-white/50 space-y-1">
|
||||
<li>• <strong className="text-white/80"><10 Cookies</strong> = Starter-Plan</li>
|
||||
<li>• <strong className="text-white/80">10-30 Cookies</strong> = Professional / Mid-Market</li>
|
||||
<li>• <strong className="text-white/80">30-60 Cookies</strong> = Enterprise</li>
|
||||
<li>• <strong className="text-white/80">>60 Cookies + Premium-Features</strong> = Premier-Tier</li>
|
||||
</ul>
|
||||
<p className="text-sm text-white/60 mt-3 mb-2">
|
||||
Parallel werden alle Vendors funktional klassifiziert (Web-Analytics,
|
||||
Werbung, CDN, Marketing-Automation, …). Mehrere Vendors in
|
||||
derselben Kategorie = Konsolidierungs-Kandidat.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</FadeInView>
|
||||
|
||||
<FadeInView delay={0.3}>
|
||||
<GlassCard>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-emerald-500/10 flex items-center justify-center flex-shrink-0">
|
||||
<Calculator className="w-6 h-6 text-emerald-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="mono-label text-emerald-400 mb-1">STUFE 4</div>
|
||||
<h3 className="text-xl font-bold mb-2">Kosten-Schaetzung + EU-Konsolidierung</h3>
|
||||
<p className="text-sm text-white/60 mb-3">
|
||||
Pro Tier multiplizieren wir mit unseren Pricing-Lookups
|
||||
(Gartner/Forrester 2025 + oeffentliche Listpreise).
|
||||
Ergebnis: jaehrlicher Kostenbereich pro Vendor.
|
||||
</p>
|
||||
<ul className="text-sm text-white/50 space-y-1">
|
||||
<li>• Master-Vertrag-Dedupe (1 Adobe-Lizenz, viele Features)</li>
|
||||
<li>• EU-Alternative mit gleicher Funktion + Listpreis</li>
|
||||
<li>• Multi-Funktions-Tools die mehrere Kategorien gleichzeitig ersetzen</li>
|
||||
<li>• Sparpotenzial = Aktuelle Listpreise − EU-Tool-Listpreis</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</FadeInView>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Caveats — ehrlich */}
|
||||
<section className="py-12 sm:py-16 bg-amber-500/[0.03]">
|
||||
<div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<h2 className="text-2xl font-bold mb-6 flex items-center gap-3">
|
||||
<AlertTriangle className="w-6 h-6 text-amber-400" />
|
||||
Was wir NICHT versprechen
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<GlassCard>
|
||||
<h3 className="font-bold mb-2">Listpreise ≠ Vertragspreise</h3>
|
||||
<p className="text-sm text-white/60">
|
||||
Konzern-Konditionen liegen ueblicherweise 30–50% unter Listpreis.
|
||||
Wir geben Bereiche an, nicht exakte Zahlen. Verifikation mit dem
|
||||
eigenen Einkauf ist Pflicht.
|
||||
</p>
|
||||
</GlassCard>
|
||||
<GlassCard>
|
||||
<h3 className="font-bold mb-2">Funktionale Redundanz ≠ Strategische Redundanz</h3>
|
||||
<p className="text-sm text-white/60">
|
||||
Mehrere Analytics-Tools koennen legitim sein (A/B-Test, regional split,
|
||||
Marketing vs Produkt). Wir nennen die bekannten Gruende explizit.
|
||||
</p>
|
||||
</GlassCard>
|
||||
<GlassCard>
|
||||
<h3 className="font-bold mb-2">Media-Spend nicht enthalten</h3>
|
||||
<p className="text-sm text-white/60">
|
||||
Google-Ads-/Meta-Ads-/Programmatic-Budget ist NICHT in der Saving-
|
||||
Schaetzung. Nur Tool-Lizenzen. Media-Optimierung ist ein separates
|
||||
Thema.
|
||||
</p>
|
||||
</GlassCard>
|
||||
<GlassCard>
|
||||
<h3 className="font-bold mb-2">Migrations-Kosten nicht abgezogen</h3>
|
||||
<p className="text-sm text-white/60">
|
||||
Tool-Wechsel kostet Zeit + interne Implementation. Faustregel:
|
||||
3-6 Monate Amortisation einrechnen. Saving-Schaetzung ist Brutto.
|
||||
</p>
|
||||
</GlassCard>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Datenquellen */}
|
||||
<section className="py-12 sm:py-16">
|
||||
<div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<h2 className="text-2xl font-bold mb-6 flex items-center gap-3">
|
||||
<Globe className="w-6 h-6 text-emerald-400" />
|
||||
Datenquellen + Updates
|
||||
</h2>
|
||||
<GlassCard>
|
||||
<ul className="text-sm text-white/60 space-y-3">
|
||||
<li>
|
||||
<strong className="text-white/90">Cookie-Wissen:</strong>{' '}
|
||||
Cookiepedia, IAB Europe TCF v2.2 Vendor-Liste, Cookiebot Public DB,
|
||||
Vendor-eigene Dokumentation
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-white/90">Pricing:</strong>{' '}
|
||||
Gartner Hype Cycle 2025, Forrester Wave MarTech 2025, oeffentliche
|
||||
Pricing-Pages, anonymisierte Kundengespraeche
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-white/90">Regulatorik:</strong>{' '}
|
||||
EDPB Cookie Guidelines 2/2023, DSK-Orientierungshilfe Telemedien 2024,
|
||||
CNIL Cookies-Recommendations
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-white/90">Updates:</strong>{' '}
|
||||
DB wird kontinuierlich gepflegt. Neue Kunden geben uns Ground-Truth
|
||||
fuer Kalibrierung.
|
||||
</li>
|
||||
</ul>
|
||||
</GlassCard>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<Footer />
|
||||
<ChatFAB />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import Navbar from '@/components/layout/Navbar'
|
||||
import Footer from '@/components/layout/Footer'
|
||||
import ChatFAB from '@/components/layout/ChatFAB'
|
||||
import PageHeader from '@/components/ui/PageHeader'
|
||||
import GlassCard from '@/components/ui/GlassCard'
|
||||
import FadeInView from '@/components/ui/FadeInView'
|
||||
import { Cookie, ShieldCheck, Mail, ArrowRight, CheckCircle2, AlertTriangle } from 'lucide-react'
|
||||
|
||||
export default function SavingsScanPage() {
|
||||
const [url, setUrl] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [consent, setConsent] = useState(true)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [done, setDone] = useState(false)
|
||||
const [checkId, setCheckId] = useState<string | null>(null)
|
||||
const [progress, setProgress] = useState<string>('')
|
||||
const [progressPct, setProgressPct] = useState<number>(0)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const pollingRef = useRef<boolean>(false)
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!url || !email) return
|
||||
setError(null)
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const res = await fetch('/api/scan/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url, email, consent }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) {
|
||||
setError(data.detail || data.error || 'Scan konnte nicht gestartet werden')
|
||||
return
|
||||
}
|
||||
setCheckId(data.check_id)
|
||||
setDone(true)
|
||||
} catch {
|
||||
setError('Netzwerkfehler — bitte erneut versuchen.')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!checkId || pollingRef.current) return
|
||||
pollingRef.current = true
|
||||
let cancelled = false
|
||||
const poll = async () => {
|
||||
for (let i = 0; i < 60 && !cancelled; i++) {
|
||||
await new Promise(r => setTimeout(r, 5000))
|
||||
try {
|
||||
const res = await fetch(`/api/scan/status/${checkId}`)
|
||||
const data = await res.json()
|
||||
if (data.progress) setProgress(data.progress)
|
||||
if (typeof data.progress_pct === 'number') setProgressPct(data.progress_pct)
|
||||
if (['completed', 'failed', 'skipped_tdm'].includes(data.status)) {
|
||||
if (data.status !== 'completed') {
|
||||
setError(data.error || 'Scan abgebrochen')
|
||||
}
|
||||
return
|
||||
}
|
||||
} catch { /* retry */ }
|
||||
}
|
||||
}
|
||||
poll()
|
||||
return () => { cancelled = true; pollingRef.current = false }
|
||||
}, [checkId])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
<main>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<PageHeader
|
||||
tag="KOSTENLOSER SAVING-SCAN"
|
||||
title="In 5 Minuten zur"
|
||||
titleHighlight="sechsstelligen Saving-Schaetzung"
|
||||
subtitle="URL eingeben — wir analysieren alle Cookies, identifizieren redundante Anbieter und schaetzen jaehrliche Einsparung. Kostenlos, ohne Login, ohne Vertrieb-Termin."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className="py-12 sm:py-16">
|
||||
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
{!done ? (
|
||||
<GlassCard>
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div>
|
||||
<label htmlFor="url" className="block text-sm font-medium text-white/70 mb-2">
|
||||
Website-URL <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="url"
|
||||
type="url"
|
||||
required
|
||||
placeholder="https://www.ihre-firma.de"
|
||||
value={url}
|
||||
onChange={e => setUrl(e.target.value)}
|
||||
className="w-full px-4 py-3 rounded-lg bg-white/[0.04] border border-white/10
|
||||
text-white placeholder-white/30 focus:border-emerald-400 focus:outline-none"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-white/40">
|
||||
Wir crawlen die Startseite + automatisch erkennbare Unterseiten
|
||||
(DSI, Impressum, Cookie-Richtlinie).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-white/70 mb-2">
|
||||
E-Mail fuer den Bericht <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
required
|
||||
placeholder="ihr.name@firma.de"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
className="w-full px-4 py-3 rounded-lg bg-white/[0.04] border border-white/10
|
||||
text-white placeholder-white/30 focus:border-emerald-400 focus:outline-none"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-white/40">
|
||||
Bericht kommt als PDF + JSON. Die Mailadresse wird ausschliesslich
|
||||
fuer diesen Scan verwendet.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label className="flex items-start gap-2 text-xs text-white/60 cursor-pointer">
|
||||
<input type="checkbox" checked={consent} onChange={e => setConsent(e.target.checked)}
|
||||
className="mt-0.5 accent-emerald-500" />
|
||||
<span>
|
||||
Ich stimme zu, dass meine E-Mail fuer den Saving-Report + ein
|
||||
einmaliges Sales-Follow-Up genutzt wird. Widerruf jederzeit per E-Mail.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-amber-300/80 bg-amber-500/10 border border-amber-400/30 rounded px-3 py-2">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting || !consent}
|
||||
className="inline-flex items-center gap-2 px-6 py-3 rounded-full
|
||||
bg-emerald-500 hover:bg-emerald-400 transition-colors
|
||||
text-enterprise-dark font-semibold disabled:opacity-50"
|
||||
>
|
||||
{submitting ? 'Wird gestartet …' : 'Saving-Scan starten'}
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<p className="text-xs text-white/40 pt-2">
|
||||
Wir analysieren ausschliesslich oeffentlich abrufbare Daten Ihrer Website
|
||||
unter Beachtung maschinenlesbarer Nutzungsvorbehalte (§ 44b UrhG).
|
||||
Pro Domain max. 1 Scan / 24h. Ergebnis innerhalb von ~3-5 Minuten per E-Mail.
|
||||
</p>
|
||||
</form>
|
||||
</GlassCard>
|
||||
) : (
|
||||
<GlassCard>
|
||||
<div className="text-center py-6">
|
||||
{error ? (
|
||||
<AlertTriangle className="w-12 h-12 text-amber-400 mx-auto mb-4" />
|
||||
) : (
|
||||
<CheckCircle2 className="w-12 h-12 text-emerald-400 mx-auto mb-4" />
|
||||
)}
|
||||
<h3 className="text-xl font-bold mb-2">
|
||||
{error ? 'Scan-Hinweis' : (progressPct >= 100 ? 'Scan abgeschlossen' : 'Scan laeuft')}
|
||||
</h3>
|
||||
{error ? (
|
||||
<p className="text-amber-300/80 mb-4 text-sm">{error}</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-white/60 mb-4">
|
||||
{progressPct >= 100
|
||||
? <>Der Bericht ist unterwegs an <strong className="text-white/90">{email}</strong>.</>
|
||||
: <>Wir analysieren <strong className="text-white/90">{url}</strong>. Bericht kommt in ~3-5 Min an <strong className="text-white/90">{email}</strong>.</>
|
||||
}
|
||||
</p>
|
||||
{progress && progressPct < 100 && (
|
||||
<div className="max-w-md mx-auto mt-4">
|
||||
<div className="text-xs text-white/50 mb-2">{progress}</div>
|
||||
<div className="w-full bg-white/10 rounded-full h-2 overflow-hidden">
|
||||
<div className="bg-emerald-400 h-full transition-all" style={{ width: `${progressPct}%` }} />
|
||||
</div>
|
||||
<div className="text-xs text-white/40 mt-1">{progressPct}%</div>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-white/40 mt-4">
|
||||
Pruefen Sie auch Ihren Spam-Ordner.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</GlassCard>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="py-16">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<FadeInView>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<GlassCard>
|
||||
<Cookie className="w-8 h-8 text-emerald-400 mb-4" />
|
||||
<h3 className="text-lg font-bold mb-2">Was wir analysieren</h3>
|
||||
<ul className="text-sm text-white/60 space-y-2">
|
||||
<li>• Alle Cookies + Vendor-Identifikation</li>
|
||||
<li>• Funktionale Kategorisierung (Analytics, Werbung, CDN, …)</li>
|
||||
<li>• Redundanz-Detection ueber Kategorien</li>
|
||||
<li>• Cookie-Tiefenanalyse mit Tier-Inferenz</li>
|
||||
</ul>
|
||||
</GlassCard>
|
||||
<GlassCard delay={0.1}>
|
||||
<ShieldCheck className="w-8 h-8 text-emerald-400 mb-4" />
|
||||
<h3 className="text-lg font-bold mb-2">Was Sie bekommen</h3>
|
||||
<ul className="text-sm text-white/60 space-y-2">
|
||||
<li>• Geschaetzte jaehrliche Tooling-Kosten (Listpreis-Range)</li>
|
||||
<li>• Sparpotenzial pro Konsolidierungs-Kandidat</li>
|
||||
<li>• EU-Alternative pro US-Vendor</li>
|
||||
<li>• Schrems-II-Risiko-Bewertung</li>
|
||||
</ul>
|
||||
</GlassCard>
|
||||
<GlassCard delay={0.2}>
|
||||
<Mail className="w-8 h-8 text-emerald-400 mb-4" />
|
||||
<h3 className="text-lg font-bold mb-2">Was es kostet</h3>
|
||||
<ul className="text-sm text-white/60 space-y-2">
|
||||
<li>• <strong className="text-white">Erster Scan: kostenlos</strong></li>
|
||||
<li>• Kein Login, kein Vertriebs-Termin</li>
|
||||
<li>• Daten werden nicht gespeichert</li>
|
||||
<li>• PDF + JSON zum Download</li>
|
||||
</ul>
|
||||
</GlassCard>
|
||||
</div>
|
||||
</FadeInView>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<Footer />
|
||||
<ChatFAB />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import Navbar from '@/components/layout/Navbar'
|
||||
import Footer from '@/components/layout/Footer'
|
||||
import ChatFAB from '@/components/layout/ChatFAB'
|
||||
|
||||
// Stärken / USP-Seite — sieben Verkaufsargumente aus der IACE-Strategie
|
||||
// (Memory: project_marketing_website_3014_themes.md). Aufgebaut als
|
||||
// Long-Form-Page mit Anker-Sprungmarken — eine Nummerierte Differenzierung
|
||||
// pro Sektion, damit Sales-Calls über tiefe Links arbeiten können.
|
||||
|
||||
const usps = [
|
||||
{
|
||||
id: 'engine',
|
||||
no: '1',
|
||||
title: 'Engine, nicht Checkliste',
|
||||
sub: 'Wir leiten Gefährdungen ab. Wettbewerb fragt aus einer Liste.',
|
||||
body:
|
||||
'Marktstandard (DesignSafe, Pilz, Sick) ist Excel-aufgewertete Checkliste: der Engineer wählt aus einer Hazard-Bibliothek aus. ' +
|
||||
'BreakPilot betreibt eine deterministische Pattern-Engine mit über 1.200 Hazard-Patterns. Aus der Maschinenbeschreibung leitet sie ' +
|
||||
'die Gefährdungen ab — keine Auswahllisten, keine vergessenen Punkte.',
|
||||
proof: 'Audit-Suite cmd/iace-audit erkennt eigene Lücken (Methode A–E)',
|
||||
},
|
||||
{
|
||||
id: 'multi-markt',
|
||||
no: '2',
|
||||
title: 'Eine Risikobeurteilung — alle Märkte',
|
||||
sub: 'CE + OSHA + ANSI + GB + JIS aus einem Datenmodell.',
|
||||
body:
|
||||
'Die gleiche Pattern-Engine generiert pro Maschinenbeschreibung mehrere Compliance-Anhänge. Hersteller wählt seine Zielmärkte. ' +
|
||||
'EU-Recht zitieren wir wörtlich (Rule 1). OWASP unter CC-BY-SA mit Pflicht-Attribution (Rule 2). DIN/EN nur per Identifier (Rule 3). ' +
|
||||
'Norm-Cross-Reference-Bibliothek mappt ISO 12100 ↔ DIN EN ISO 12100 ↔ ANSI B11.0 ↔ GB/T 15706 ↔ JIS B 9700.',
|
||||
proof: '252 Regulationen klassifiziert · 314.811 Controls audited',
|
||||
},
|
||||
{
|
||||
id: 'folgegefahren',
|
||||
no: '3',
|
||||
title: 'Vom Bediener bis zum Endkunden',
|
||||
sub: 'Folgegefahren-Modell mit Sekundärschadens-Kette.',
|
||||
body:
|
||||
'Klassische Risikobeurteilung schaut nur den Bediener an. Wir modellieren die Schadenskette weiter: Glasbruch in der Abfüllanlage ' +
|
||||
'verletzt nicht nur den Bediener, sondern erreicht über Restsplitter den Endkunden. BreakPilot verbindet CE-Sicherheit mit ' +
|
||||
'Produkthaftung nach ProdHaftG, Lebensmittelrecht nach VO 178/2002 und ISO 31000 Unternehmensrisiko in einem Datenmodell.',
|
||||
proof: 'SecondaryHarm-Modell live für consumer_safety, product_liability, food_safety, environmental, reputation, financial',
|
||||
},
|
||||
{
|
||||
id: 'public-domain',
|
||||
no: '4',
|
||||
title: 'Public Domain als Rechtsanker',
|
||||
sub: 'Werte aus OSHA, NIST, EUR-Lex, BAuA — auditfähig zitiert.',
|
||||
body:
|
||||
'Mindestabstände der Maschinensicherheit kommen bei uns aus OSHA 29 CFR 1910 Subpart O — US Federal Public Domain, lizenzrechtlich ' +
|
||||
'unbedenklich. Engineering-Rundung auf safe-side mm-Raster wird transparent dokumentiert. EU-Normen erscheinen nur als Identifier-Verweis ' +
|
||||
'mit einer menschlich kuratierten "Strenger/Gleich/Weicher"-Annotation — kein Copyright-Risiko.',
|
||||
proof: 'OSHA Table O-10 + §1910.217 PSDI-Formel verbatim · DIN nur Identifier · 6 DGUV-Publikationen referenziert',
|
||||
},
|
||||
{
|
||||
id: 'audit-suite',
|
||||
no: '5',
|
||||
title: 'Audit findet Lücken, die der Fachmann übersieht',
|
||||
sub: 'Fünf deterministische Audits ohne Ground Truth.',
|
||||
body:
|
||||
'Unsere Engine kennt ihre eigenen Lücken. Methode A bis E (Reachability, Consistency, Vocabulary, Echo, Hierarchy) finden Gaps ' +
|
||||
'ohne Fachmann-Vergleich. Bei einem Test fanden wir 100 strukturell unerreichbare Patterns und 46 unvollständige Component-Tags — ' +
|
||||
'Probleme, die ein menschlicher Auditor in einem Einzelfall nie gesehen hätte.',
|
||||
proof: 'cmd/iace-audit · 1.213 Patterns transparent · 99,94% Recall verifiziert',
|
||||
},
|
||||
{
|
||||
id: 'made-in-germany',
|
||||
no: '6',
|
||||
title: 'Made in Germany meets US Federal Public Domain',
|
||||
sub: 'Deutscher Maschinenbau, der gleichzeitig US-Compliance liefert.',
|
||||
body:
|
||||
'Deutscher Exportweltmeister-Maschinenbau braucht UL/NRTL-Zulassung für die USA. Die gleichen Daten, die wir für CE generieren, ' +
|
||||
'liefern dem US-Auditor 80 % der Vorarbeit. Risikobeurteilung in einer Sprache, Compliance in zwei Märkten — ohne Mehraufwand für den Hersteller.',
|
||||
proof: 'OSHA-Anker im RAG · NRTL-fähige Compliance-Spur · DesignSafe-Marktstandard wird hier erweitert, nicht imitiert',
|
||||
},
|
||||
{
|
||||
id: 'tooling',
|
||||
no: '7',
|
||||
title: 'LLM-Gap-Review als Co-Pilot, nicht als Roboter-Anwalt',
|
||||
sub: 'Pattern-Engine als Audit-Spur, LLM als Lücken-Suchhund.',
|
||||
body:
|
||||
'Die deterministische Engine bleibt die auditfähige Quelle der Wahrheit. Ein nachgelagerter LLM-Gap-Review (Qwen / Claude) prüft, ' +
|
||||
'was die Engine übersehen hat — mit klarer Quellen-Provenance (R3 LLM-Review) und Adopt/Reject-UX. Halluzinationen können nicht in ' +
|
||||
'die finale Risikobeurteilung schlüpfen.',
|
||||
proof: 'POST /projects/:id/llm-gap-review · Konfidenz-Stufen · Fallback auf statische Checkliste',
|
||||
},
|
||||
] as const
|
||||
|
||||
const competitors = [
|
||||
{ feature: 'Pattern-Engine statt Checkliste', bp: '✓', ds: '—', pilz: '—', sick: '—', sphera: '—' },
|
||||
{ feature: 'Multi-Markt CE / US / CN / JP', bp: '✓', ds: 'nur US', pilz: 'nur EU', sick: 'nur EU', sphera: 'enterprise' },
|
||||
{ feature: 'Folgegefahren-Modell', bp: '✓', ds: '—', pilz: '—', sick: '—', sphera: 'Process' },
|
||||
{ feature: 'Audit-Suite (Engine-Lücken-Erkennung)', bp: '✓', ds: '—', pilz: '—', sick: '—', sphera: '—' },
|
||||
{ feature: 'OSHA-Anker (Public Domain Werte)', bp: '✓', ds: '✓', pilz: '—', sick: '—', sphera: '—' },
|
||||
{ feature: 'LLM-Gap-Review (Co-Pilot)', bp: '✓', ds: '—', pilz: '—', sick: '—', sphera: '—' },
|
||||
]
|
||||
|
||||
export default function StaerkenPage() {
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
<main className="bg-enterprise-dark text-white pt-32 pb-24">
|
||||
<div className="max-w-5xl mx-auto px-4">
|
||||
<header className="mb-16">
|
||||
<h1 className="text-5xl font-bold mb-4">Was uns differenziert</h1>
|
||||
<p className="text-white/60 text-lg max-w-3xl">
|
||||
Sieben konkrete Punkte, die BreakPilot von DesignSafe, Pilz, Sick, TÜV-Tools und Sphera trennen.
|
||||
Jede Differenzierung ist im Produkt umgesetzt — kein Marketing-Versprechen.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<ol className="space-y-12">
|
||||
{usps.map((u) => (
|
||||
<li id={u.id} key={u.id} className="border-l-2 border-accent-electric pl-6">
|
||||
<div className="flex items-baseline gap-3 mb-2">
|
||||
<span className="text-accent-electric font-mono text-3xl font-bold">#{u.no}</span>
|
||||
<h2 className="text-2xl font-semibold">{u.title}</h2>
|
||||
</div>
|
||||
<p className="text-accent-electric/80 text-sm mb-3">{u.sub}</p>
|
||||
<p className="text-white/70 leading-relaxed mb-3">{u.body}</p>
|
||||
<p className="text-white/40 text-xs">
|
||||
<span className="text-white/60">Belegt durch:</span> {u.proof}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<section className="mt-20">
|
||||
<h2 className="text-3xl font-bold mb-4">Direktvergleich</h2>
|
||||
<p className="text-white/60 mb-6 max-w-3xl">
|
||||
Stand 2026. Marktangaben basieren auf öffentlicher Produktinformation der genannten Anbieter.
|
||||
</p>
|
||||
<div className="overflow-x-auto border border-white/10 rounded-lg">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-white/[0.04] border-b border-white/10">
|
||||
<tr>
|
||||
<th className="text-left p-3 font-medium">Feature</th>
|
||||
<th className="text-left p-3 font-medium text-accent-electric">BreakPilot</th>
|
||||
<th className="text-left p-3 font-medium text-white/60">DesignSafe</th>
|
||||
<th className="text-left p-3 font-medium text-white/60">Pilz PASS</th>
|
||||
<th className="text-left p-3 font-medium text-white/60">Sick SD</th>
|
||||
<th className="text-left p-3 font-medium text-white/60">Sphera</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{competitors.map((c) => (
|
||||
<tr key={c.feature} className="border-t border-white/[0.06]">
|
||||
<td className="p-3 text-white/80">{c.feature}</td>
|
||||
<td className="p-3 text-accent-electric font-medium">{c.bp}</td>
|
||||
<td className="p-3 text-white/50">{c.ds}</td>
|
||||
<td className="p-3 text-white/50">{c.pilz}</td>
|
||||
<td className="p-3 text-white/50">{c.sick}</td>
|
||||
<td className="p-3 text-white/50">{c.sphera}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-20 border-t border-white/10 pt-12">
|
||||
<h2 className="text-2xl font-bold mb-3">Quellen & Lizenz-Architektur</h2>
|
||||
<p className="text-white/60 leading-relaxed">
|
||||
Die Plattform stützt sich auf öffentliche Quellen: EU-Recht (EUR-Lex), Bundesrecht (BetrSichV, ArbSchG),
|
||||
US Federal Code (OSHA, NIST), Behörden-Leitfäden (ENISA, EDPB, BAuA), freie Sicherheits-Frameworks unter
|
||||
CC-BY-SA (OWASP). Jeder Inhalt trägt eine deterministische Lizenzregel R1/R2/R3 und löst die
|
||||
entsprechende Attribution im Ausgabe-PDF und im Frontend automatisch aus. Vollständige Quellenliste
|
||||
im SDK unter <code className="bg-white/[0.06] px-1.5 py-0.5 rounded">/sdk/licenses</code>.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
<ChatFAB />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
'use client'
|
||||
|
||||
import { TrendingDown, Cookie, Scale, AlertTriangle, ArrowRight } from 'lucide-react'
|
||||
import { t } from '@/lib/content'
|
||||
import { useApp } from '@/lib/context'
|
||||
import SectionHeading from '@/components/ui/SectionHeading'
|
||||
import GlassCard from '@/components/ui/GlassCard'
|
||||
import FadeInView from '@/components/ui/FadeInView'
|
||||
import GradientText from '@/components/ui/GradientText'
|
||||
|
||||
const pillarIcons = [Cookie, Scale, AlertTriangle]
|
||||
|
||||
export default function SavingsSection() {
|
||||
const { lang } = useApp()
|
||||
const i = t(lang)
|
||||
const s = (i as unknown as { savings: typeof i.problem & { promise: string; caseStudy: { label: string; headline: string; bullets: string[]; saving: string; side: string }; pillars: { title: string; description: string }[]; cta: string; ctaSecondary: string } }).savings
|
||||
|
||||
return (
|
||||
<section id="savings" className="py-24 sm:py-32 relative">
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-emerald-500/[0.02] to-transparent" />
|
||||
<div className="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<SectionHeading
|
||||
tag={s.tag}
|
||||
title={s.title}
|
||||
titleHighlight={s.titleHighlight}
|
||||
subtitle={s.subtitle}
|
||||
/>
|
||||
|
||||
{/* PROMISE — high-impact one-liner */}
|
||||
<FadeInView>
|
||||
<div className="text-center mb-16">
|
||||
<div className="inline-flex items-center gap-3 px-6 py-3 rounded-full
|
||||
border border-emerald-400/30 bg-emerald-500/[0.08]">
|
||||
<TrendingDown className="w-5 h-5 text-emerald-400" />
|
||||
<span className="text-lg font-semibold text-emerald-300">
|
||||
{s.promise}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</FadeInView>
|
||||
|
||||
{/* CASE STUDY card */}
|
||||
<FadeInView>
|
||||
<div className="rounded-2xl border border-emerald-400/20 bg-gradient-to-br
|
||||
from-emerald-500/[0.06] to-white/[0.02] p-8 mb-16">
|
||||
<div className="mono-label text-emerald-400 mb-3 tracking-widest">
|
||||
{s.caseStudy.label}
|
||||
</div>
|
||||
<h3 className="text-2xl sm:text-3xl font-bold mb-6">
|
||||
{s.caseStudy.headline}
|
||||
</h3>
|
||||
<ul className="space-y-3 mb-8">
|
||||
{s.caseStudy.bullets.map((b: string, idx: number) => (
|
||||
<li key={idx} className="flex items-start gap-3 text-white/70">
|
||||
<span className="mt-1 w-1.5 h-1.5 rounded-full bg-emerald-400 flex-shrink-0" />
|
||||
<span className="text-sm">{b}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 pt-6 border-t border-white/10">
|
||||
<div>
|
||||
<div className="mono-label text-white/40 mb-1">JAEHRLICHES SPARPOTENZIAL</div>
|
||||
<div className="text-3xl font-bold">
|
||||
<GradientText>{s.caseStudy.saving}</GradientText>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mono-label text-white/40 mb-1">PLUS</div>
|
||||
<div className="text-sm text-white/70">{s.caseStudy.side}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FadeInView>
|
||||
|
||||
{/* Pillars: HOW we do it */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-16">
|
||||
{s.pillars.map((p: { title: string; description: string }, idx: number) => {
|
||||
const Icon = pillarIcons[idx] || Cookie
|
||||
return (
|
||||
<GlassCard key={idx} delay={idx * 0.1}>
|
||||
<div className="w-12 h-12 rounded-xl bg-emerald-500/10 flex items-center justify-center mb-4">
|
||||
<Icon className="w-6 h-6 text-emerald-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-bold mb-2">{p.title}</h3>
|
||||
<p className="text-sm text-white/50">{p.description}</p>
|
||||
</GlassCard>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* CTA */}
|
||||
<FadeInView>
|
||||
<div className="text-center">
|
||||
<a
|
||||
href="/savings-scan"
|
||||
className="inline-flex items-center gap-2 px-6 py-3 rounded-full
|
||||
bg-emerald-500 hover:bg-emerald-400 transition-colors
|
||||
text-enterprise-dark font-semibold"
|
||||
>
|
||||
{s.cta}
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</a>
|
||||
<a
|
||||
href="/savings-methodik"
|
||||
className="inline-flex items-center gap-2 px-6 py-3 ml-3
|
||||
text-white/60 hover:text-white transition-colors text-sm"
|
||||
>
|
||||
{s.ctaSecondary}
|
||||
</a>
|
||||
</div>
|
||||
</FadeInView>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Savings-Section content (DE + EN) — ausgelagert aus content.ts,
|
||||
* damit content.ts unter dem 500-LOC-Hard-Cap bleibt.
|
||||
*/
|
||||
|
||||
export const savingsDE = {
|
||||
tag: '03 / NEBENEFFEKT: COST-OPTIMIZATION',
|
||||
title: 'Compliance entdeckt',
|
||||
titleHighlight: 'sechsstellige Einsparungen.',
|
||||
subtitle:
|
||||
'Beim Cookie-Audit identifizieren wir Anbieter, die das Gleiche tun. ' +
|
||||
'Pro Anbieter eine Kosten-Schaetzung auf Basis von Tier-Inferenz ' +
|
||||
'(Cookie-Anzahl, Premium-Feature-Detection, Drittanbieter-Quote).',
|
||||
promise: 'Software bezahlt sich beim ERSTEN Scan.',
|
||||
caseStudy: {
|
||||
label: 'CASE-STUDY: DAX-Automotive-Konzern',
|
||||
headline: '90 Cookie-Anbieter → 25 nach Konsolidierung',
|
||||
bullets: [
|
||||
'5 Web-Analytics-Tools (Adobe Analytics, Content Square, Dynatrace, …) → 1 Matomo Pro',
|
||||
'10 Retargeting-Pixel (Criteo, Adform, Outbrain, Taboola, Meta, …) → 3 Kern-Kanaele',
|
||||
'5 CDN/Speed-Tools (Akamai, AWS, Baqend, Speedkit, SpeedCurve) → IONOS Cloud + Bunny',
|
||||
],
|
||||
saving: '€500k–€3M / Jahr',
|
||||
side: 'Plus 100% DSGVO-Konformitaet ohne Schrems-II-Risiko',
|
||||
},
|
||||
pillars: [
|
||||
{
|
||||
title: 'Tier-Inferenz aus Cookie-Footprint',
|
||||
description:
|
||||
'>30 Cookies = Enterprise. Premium-Feature-Cookies (s_target_qa, _ab_test, ' +
|
||||
'aam_uuid) markieren Add-on-Module. Drittanbieter-Quote + Lebensdauer ' +
|
||||
'verraten Tracking-Intensitaet.',
|
||||
},
|
||||
{
|
||||
title: 'Konsolidierungs-Vorschlaege',
|
||||
description:
|
||||
'Pro Funktions-Kategorie (Analytics, Werbung, CDN, …) ein konkreter ' +
|
||||
'EU-Ersatz inkl. Listpreis. Multi-Funktions-Tools (SAP CX, Matomo Pro, ' +
|
||||
'IONOS Cloud) ersetzen mehrere Anbieter gleichzeitig.',
|
||||
},
|
||||
{
|
||||
title: 'Ehrliche Caveats',
|
||||
description:
|
||||
'Bekannte Gruende fuer Mehrfach-Einsatz (Saisonal, A/B-Test, Regional) ' +
|
||||
'werden explizit aufgefuehrt — kein Pseudo-Saving. Konzern-Konditionen ' +
|
||||
'liegen ueblicherweise 30-50% unter Listpreis (transparent ausgewiesen).',
|
||||
},
|
||||
],
|
||||
cta: 'Kostenlosen 5-Min-Saving-Scan starten',
|
||||
ctaSecondary: 'Methodik ansehen',
|
||||
}
|
||||
|
||||
export const savingsEN = {
|
||||
tag: '03 / SIDE EFFECT: COST OPTIMIZATION',
|
||||
title: 'Compliance reveals',
|
||||
titleHighlight: 'six-figure savings.',
|
||||
subtitle:
|
||||
'During the cookie audit we identify vendors that do the same thing. ' +
|
||||
'Per vendor we infer the pricing tier from cookie footprint (count, ' +
|
||||
'premium-feature detection, third-party ratio).',
|
||||
promise: 'The software pays for itself with the FIRST scan.',
|
||||
caseStudy: {
|
||||
label: 'CASE STUDY: DAX automotive group',
|
||||
headline: '90 cookie vendors → 25 after consolidation',
|
||||
bullets: [
|
||||
'5 Web-Analytics tools (Adobe Analytics, Content Square, Dynatrace, …) → 1 Matomo Pro',
|
||||
'10 retargeting pixels (Criteo, Adform, Outbrain, Taboola, Meta, …) → 3 core channels',
|
||||
'5 CDN/Speed tools (Akamai, AWS, Baqend, Speedkit, SpeedCurve) → IONOS Cloud + Bunny',
|
||||
],
|
||||
saving: '€500k–€3M / year',
|
||||
side: 'Plus 100% GDPR-compliant without Schrems II risk',
|
||||
},
|
||||
pillars: [
|
||||
{
|
||||
title: 'Tier inference from cookie footprint',
|
||||
description:
|
||||
'>30 cookies = enterprise. Premium-feature cookies (s_target_qa, _ab_test, ' +
|
||||
'aam_uuid) mark add-on modules. Third-party share + lifetime reveal ' +
|
||||
'tracking intensity.',
|
||||
},
|
||||
{
|
||||
title: 'Consolidation suggestions',
|
||||
description:
|
||||
'Per functional category (analytics, advertising, CDN, …) a concrete ' +
|
||||
'EU substitute incl. list price. Multi-function tools (SAP CX, Matomo Pro, ' +
|
||||
'IONOS Cloud) replace multiple vendors at once.',
|
||||
},
|
||||
{
|
||||
title: 'Honest caveats',
|
||||
description:
|
||||
'Known reasons for multi-vendor use (seasonal, A/B testing, regional) ' +
|
||||
'are stated explicitly — no pseudo-savings. Group discounts typically ' +
|
||||
'30–50% below list price (transparently disclosed).',
|
||||
},
|
||||
],
|
||||
cta: 'Run the free 5-min savings scan',
|
||||
ctaSecondary: 'See the methodology',
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { savingsDE, savingsEN } from './content.savings'
|
||||
|
||||
type Lang = 'de' | 'en'
|
||||
type TerminalType = 'input' | 'output' | 'signal'
|
||||
type Status = 'neutral' | 'success' | 'warning'
|
||||
@@ -64,8 +66,9 @@ const de = {
|
||||
{ label: 'Status', value: 'Aktionsplan erstellt', status: 'success' as Status },
|
||||
],
|
||||
},
|
||||
savings: savingsDE,
|
||||
deterministic: {
|
||||
tag: '03 / VERTRAUENSWÜRDIG DURCH DESIGN',
|
||||
tag: '04 / VERTRAUENSWÜRDIG DURCH DESIGN',
|
||||
title: 'Keine Halluzinationen.',
|
||||
titleHighlight: 'Konstruktionsbedingt.',
|
||||
subtitle: 'Jede Compliance-Entscheidung ist auf eine konkrete Rechtsquelle rückführbar.',
|
||||
@@ -296,8 +299,9 @@ const en = {
|
||||
{ label: 'Status', value: 'Action plan created', status: 'success' as Status },
|
||||
],
|
||||
},
|
||||
savings: savingsEN,
|
||||
deterministic: {
|
||||
tag: '03 / TRUSTWORTHY BY DESIGN',
|
||||
tag: '04 / TRUSTWORTHY BY DESIGN',
|
||||
title: 'No hallucinations.',
|
||||
titleHighlight: 'By design.',
|
||||
subtitle: 'Every compliance decision is traceable to a concrete legal source.',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Navbar links — route-based navigation
|
||||
export const navLinks = [
|
||||
{ href: '/plattform', labelDe: 'Plattform', labelEn: 'Platform' },
|
||||
{ href: '/staerken', labelDe: 'Stärken', labelEn: 'Differentiators' },
|
||||
{ href: '/ce-prozess', labelDe: 'CE-Prozess', labelEn: 'CE Process' },
|
||||
{ href: '/product-compliance', labelDe: 'Product Compliance', labelEn: 'Product Compliance' },
|
||||
{ href: '/architektur', labelDe: 'Architektur', labelEn: 'Architecture' },
|
||||
|
||||
@@ -1,478 +1,488 @@
|
||||
import { PrintPage, SectionTitle, PrintTable, COLORS } from './PrintLayout'
|
||||
import { Language, FMResult } from '@/lib/types'
|
||||
import { Language } from '@/lib/types'
|
||||
import { Page, COLORS, Callout, DataTable, ThreeCol, Bullets } from './PrintLayout'
|
||||
import { ArchitectureDiagram, PipelineFlow } from './PrintDiagrams'
|
||||
|
||||
interface SlideBase { lang: Language; pageNum: number; totalPages: number; versionName: string }
|
||||
|
||||
const STRATEGY_PHASES_DE = [
|
||||
{ title: 'Phase 1: Foundation', period: 'Aug 2026 – Jun 2027', team: '5 MA', arr: '75–150k EUR', items: ['Security Engineer + CE-Risikoingenieur als erste Hires', '5 Pilotkunden im Maschinenbau', 'Gründer verkaufen selbst', 'Product-Market Fit beweisen'] },
|
||||
{ title: 'Phase 2: Traction', period: 'Jul 2027 – Jun 2028', team: '10 MA', arr: '0,5–1,2M EUR', items: ['Channel Manager für Bechtle/CANCOM', 'DevSecOps + KI-Ingenieur', 'Lösungsberater für Partner-Demos', 'Wiederholbarer Vertriebsprozess'] },
|
||||
{ title: 'Phase 3: Scale', period: 'Jul 2028 – Jun 2029', team: '17→25 MA', arr: '2–4M EUR', items: ['Erster Direktvertrieb neben Channel', 'Compliance-Jurist für Glaubwürdigkeit', 'Security-Analyst / Pentester', 'VP Sales übernimmt vom CEO'] },
|
||||
{ title: 'Phase 4: Leadership', period: 'Jul 2029 – Dez 2030', team: '25→35 MA', arr: '4–10M EUR', items: ['EU-Expansion (AT, CH, Benelux)', 'Enterprise-Vertrieb', 'Developer Relations (Snyk-Modell)', 'Break-Even oder Series A'] },
|
||||
]
|
||||
const STRATEGY_PHASES_EN = [
|
||||
{ title: 'Phase 1: Foundation', period: 'Aug 2026 – Jun 2027', team: '5 emp.', arr: '75–150k EUR', items: ['Security Engineer + CE Risk Engineer as first hires', '5 pilot customers in manufacturing', 'Founders sell themselves', 'Prove product-market fit'] },
|
||||
{ title: 'Phase 2: Traction', period: 'Jul 2027 – Jun 2028', team: '10 emp.', arr: '0.5–1.2M EUR', items: ['Channel Manager for Bechtle/CANCOM', 'DevSecOps + AI engineer', 'Solutions engineer for partner demos', 'Repeatable sales process'] },
|
||||
{ title: 'Phase 3: Scale', period: 'Jul 2028 – Jun 2029', team: '17→25 emp.', arr: '2–4M EUR', items: ['First direct sales alongside channel', 'Compliance lawyer for credibility', 'Security analyst / pentester', 'VP Sales takes over from CEO'] },
|
||||
{ title: 'Phase 4: Leadership', period: 'Jul 2029 – Dez 2030', team: '25→35 emp.', arr: '4–10M EUR', items: ['EU expansion (AT, CH, Benelux)', 'Enterprise sales', 'Developer Relations (Snyk model)', 'Break-even or Series A'] },
|
||||
]
|
||||
/* The Anhang divider lives in PrintNewSlides.tsx so this file stays under
|
||||
* the 500-LOC cap. */
|
||||
|
||||
/* ===== STRATEGY / GO-TO-MARKET ===== */
|
||||
|
||||
export function PrintStrategyPage({ lang, pageNum, totalPages, versionName }: SlideBase) {
|
||||
const de = lang === 'de'
|
||||
const phases = de ? STRATEGY_PHASES_DE : STRATEGY_PHASES_EN
|
||||
const phases = [
|
||||
{
|
||||
n: '01', t: de ? 'Pilot, Jul/Aug 2026' : 'Pilot, Jul/Aug 2026',
|
||||
subtitle: de ? 'Validierung im DACH-Maschinenbau' : 'DACH manufacturing validation',
|
||||
items: de ? [
|
||||
'GmbH-Gründung Breakpilot COMPLAI',
|
||||
'Direktvertrieb an Maschinenbauer 10–250 MA',
|
||||
'White-Glove-Onboarding: persönlich, hands-on',
|
||||
'2 Referenzkunden aus Region Konstanz/Bodensee',
|
||||
'Case Studies, Testimonials, Referenz-Calls',
|
||||
'Ziel: 2 zahlende Kunden, ARR €30–50k',
|
||||
] : [
|
||||
'GmbH incorporation Breakpilot COMPLAI',
|
||||
'Direct sales to manufacturers 10–250 emp.',
|
||||
'White-glove onboarding: personal, hands-on',
|
||||
'2 reference customers from Konstanz/Bodensee region',
|
||||
'Case studies, testimonials, reference calls',
|
||||
'Goal: 2 paying customers, ARR €30–50k',
|
||||
],
|
||||
kpi: de ? '2 Kunden · ARR €40k' : '2 customers · ARR €40k',
|
||||
tone: COLORS.indigo600,
|
||||
},
|
||||
{
|
||||
n: '02', t: de ? 'Skalierung, 2027' : 'Scale, 2027',
|
||||
subtitle: de ? 'Channel-Partnerschaften, IHK, Messen' : 'Channel partnerships, IHK, fairs',
|
||||
items: de ? [
|
||||
'Channel-Partnerschaften mit IT-Systemhäusern (5–10)',
|
||||
'IHK-Kooperationen Bodensee, Stuttgart, München',
|
||||
'VDMA-Kooperation, Hannover Messe, IT-SA',
|
||||
'Content-Marketing: Compliance-Webinare 2× / Monat',
|
||||
'Inbound-Funnel + SEO für AI-Act / NIS2 / CRA',
|
||||
'Headcount: 5–7 (Eng + 2 Sales + 1 CSM)',
|
||||
'Ziel: 50–80 Kunden, ARR €1,2–2M',
|
||||
] : [
|
||||
'Channel partnerships with IT integrators (5–10)',
|
||||
'IHK partnerships Bodensee, Stuttgart, Munich',
|
||||
'VDMA cooperation, Hannover Messe, IT-SA',
|
||||
'Content marketing: compliance webinars 2× / month',
|
||||
'Inbound funnel + SEO for AI Act / NIS2 / CRA',
|
||||
'Headcount: 5–7 (eng + 2 sales + 1 CSM)',
|
||||
'Goal: 50–80 customers, ARR €1.2–2M',
|
||||
],
|
||||
kpi: de ? '50–80 Kunden · ARR €1,5M' : '50–80 customers · ARR €1.5M',
|
||||
tone: COLORS.indigo600,
|
||||
},
|
||||
{
|
||||
n: '03', t: de ? 'Expansion, 2028 +' : 'Expansion, 2028 +',
|
||||
subtitle: de ? 'EU-Skalierung & Enterprise' : 'EU scale & enterprise',
|
||||
items: de ? [
|
||||
'Enterprise-Kunden (50–500 MA): dedicated AE-Team',
|
||||
'EU-Expansion: AT (DACH-nativ), CH, Benelux',
|
||||
'Distributor-Partnerschaften für Frankreich, Italien',
|
||||
'Branchenausweitung: Automotive, Pharma, Energie',
|
||||
'Series A vorbereiten (€8–12M, Q2 2029)',
|
||||
'Break-Even Q3 / 2029',
|
||||
'Ziel: 200+ Kunden, ARR €8–12M',
|
||||
] : [
|
||||
'Enterprise customers (50–500 emp.): dedicated AE team',
|
||||
'EU expansion: AT (DACH-native), CH, Benelux',
|
||||
'Distributor partnerships for France, Italy',
|
||||
'Industry expansion: Automotive, Pharma, Energy',
|
||||
'Prepare Series A (€8–12M, Q2 2029)',
|
||||
'Break-even Q3 / 2029',
|
||||
'Goal: 200+ customers, ARR €8–12M',
|
||||
],
|
||||
kpi: de ? '200+ Kunden · Break-Even' : '200+ customers · break-even',
|
||||
tone: COLORS.emerald600,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<PrintPage title={de ? 'Strategie' : 'Strategy'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
|
||||
<SectionTitle subtitle={de ? 'Channel-first. Kein Wettbewerber verbindet Code-Security mit Compliance-Automatisierung.' : 'Channel-first. No competitor combines code security with compliance automation.'}>
|
||||
{de ? 'Anhang · Strategie' : 'Appendix · Strategy'}
|
||||
</SectionTitle>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '8px', marginBottom: '10px' }}>
|
||||
{[
|
||||
{ c: '#ef4444', t: de ? 'Code Security' : 'Code Security', s: 'Snyk, Checkmarx, Veracode', q: de ? '„47 Schwachstellen gefunden. CRA-konform? Nicht unser Problem."' : '"Found 47 vulnerabilities. CRA compliant? Not our problem."' },
|
||||
{ c: COLORS.indigo, t: 'BreakPilot COMPLAI', s: de ? 'Verbindet beides' : 'Combines both', q: de ? '„Code gescannt, SBOM generiert, CRA gemappt, TOM aktualisiert, CE-Ordner fertig."' : '"Code scanned, SBOM generated, CRA mapped, TOM updated, CE folder ready."' },
|
||||
{ c: '#06b6d4', t: de ? 'Compliance' : 'Compliance', s: 'DataGuard, Vanta, Drata', q: de ? '„Dokumentation fertig. Code sicher? Brauchen Sie ein anderes Tool."' : '"Documentation done. Code secure? You need a different tool."' },
|
||||
].map(b => (
|
||||
<div key={b.t} style={{ border: `1px solid ${COLORS.border}`, borderRadius: '6px', padding: '8px 10px', borderTop: `3px solid ${b.c}`, WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
<p style={{ fontSize: '11px', fontWeight: 700, color: b.c, margin: '0 0 2px' }}>{b.t}</p>
|
||||
<p style={{ fontSize: '8px', color: COLORS.light, margin: '0 0 4px' }}>{b.s}</p>
|
||||
<p style={{ fontSize: '9px', color: COLORS.med, margin: 0, fontStyle: 'italic', lineHeight: 1.4 }}>{b.q}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p style={{ fontSize: '9px', fontWeight: 700, color: COLORS.indigo, textTransform: 'uppercase', letterSpacing: '0.06em', margin: '0 0 6px' }}>{de ? 'Firmenaufbau in 4 Phasen' : 'Company Building in 4 Phases'}</p>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '8px', marginBottom: '10px' }}>
|
||||
{phases.map(p => (
|
||||
<div key={p.title} style={{ border: `1px solid ${COLORS.border}`, borderRadius: '6px', padding: '8px 10px', borderTop: `2px solid ${COLORS.indigo}` }}>
|
||||
<p style={{ fontSize: '10px', fontWeight: 700, color: COLORS.indigo, margin: '0 0 2px' }}>{p.title}</p>
|
||||
<p style={{ fontSize: '8px', color: COLORS.light, margin: '0 0 2px' }}>{p.period}</p>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '8px', color: COLORS.med, margin: '0 0 6px' }}>
|
||||
<span>{p.team}</span><span style={{ fontWeight: 700, color: COLORS.indigo }}>{p.arr}</span>
|
||||
<Page kicker="16" section={de ? 'ANHANG · STRATEGIE' : 'APPENDIX · STRATEGY'} title={de ? 'Vom Pilot zum skalierbaren Vertrieb in drei Phasen.' : 'From pilot to scalable sales in three phases.'} subtitle={de ? 'Direkter Vertrieb in Phase 1, Channel in Phase 2, Enterprise + EU-Skalierung in Phase 3. Break-Even Q3 / 2029.' : 'Direct sales in phase 1, channel in phase 2, enterprise + EU scale in phase 3. Break-even Q3 / 2029.'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
|
||||
|
||||
<ThreeCol cols={phases.map((p, i) => {
|
||||
// Split "2 Kunden · ARR €40k" into pieces for a richer outcome block
|
||||
const kpiParts = p.kpi.split(' · ')
|
||||
return (
|
||||
<div key={i} style={{ borderLeft: `2px solid ${p.tone}`, paddingLeft: '5mm', display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<span style={{ fontSize: '32pt', fontWeight: 800, color: p.tone, lineHeight: 1, fontVariantNumeric: 'tabular-nums', letterSpacing: '-0.02em' }}>{p.n}</span>
|
||||
<div style={{ fontSize: '13pt', fontWeight: 700, color: COLORS.slate900, marginTop: '3mm', lineHeight: 1.2 }}>{p.t}</div>
|
||||
<div style={{ fontSize: '8.5pt', color: p.tone, fontWeight: 600, marginBottom: '3mm' }}>{p.subtitle}</div>
|
||||
<div style={{ flex: 1, minHeight: 0 }}>
|
||||
<Bullets dense items={p.items} />
|
||||
</div>
|
||||
<ul style={{ margin: 0, paddingLeft: '12px', fontSize: '8px', color: COLORS.med, lineHeight: 1.45 }}>
|
||||
{p.items.map(i => <li key={i}>{i}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '8px' }}>
|
||||
<div style={{ border: `1px solid ${COLORS.border}`, borderRadius: '6px', padding: '10px', borderTop: `2px solid #3b82f6` }}>
|
||||
<p style={{ fontSize: '11px', fontWeight: 700, color: '#3b82f6', margin: '0 0 2px' }}>CANCOM Cloud Marketplace</p>
|
||||
<p style={{ fontSize: '8px', color: COLORS.light, margin: '0 0 4px' }}>{de ? 'TecDAX · ~5.800 MA · 120+ SaaS-Produkte' : 'TecDAX · ~5,800 emp. · 120+ SaaS products'}</p>
|
||||
<p style={{ fontSize: '9px', color: COLORS.med, margin: 0, lineHeight: 1.5 }}>{de ? 'Formales ISV-Partnerprogramm, Marketplace-Listing in 3-6 Monaten, hunderte CANCOM-Vertriebsmitarbeiter co-sellen.' : 'Formal ISV partner program, marketplace listing in 3-6 months, hundreds of CANCOM reps co-sell.'}</p>
|
||||
</div>
|
||||
<div style={{ border: `1px solid ${COLORS.border}`, borderRadius: '6px', padding: '10px', borderTop: `2px solid #10b981` }}>
|
||||
<p style={{ fontSize: '11px', fontWeight: 700, color: '#10b981', margin: '0 0 2px' }}>Bechtle Systemhäuser</p>
|
||||
<p style={{ fontSize: '8px', color: COLORS.light, margin: '0 0 4px' }}>{de ? '15.000 MA · 85+ Standorte · 6,3 Mrd. EUR · 70.000 Kunden' : '15,000 emp. · 85+ locations · EUR 6.3B · 70,000 customers'}</p>
|
||||
<p style={{ fontSize: '9px', color: COLORS.med, margin: 0, lineHeight: 1.5 }}>{de ? 'Regionaler Einstieg mit lokalem Systemhaus, Champion evangelisiert intern, nationale Listung nach Pilot-Erfolg (12-18 Monate).' : 'Regional entry with local system house, champion evangelizes internally, national listing after pilot success (12-18 months).'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p style={{ fontSize: '8px', color: COLORS.light, margin: '6px 0 0', fontStyle: 'italic' }}>
|
||||
{de ? '* CANCOM und Bechtle sind geplante Distributionspartner. Eine Kontaktaufnahme ist noch nicht erfolgt.' : '* CANCOM and Bechtle are planned distribution partners. No contact has been made yet.'}
|
||||
</p>
|
||||
</PrintPage>
|
||||
)
|
||||
}
|
||||
|
||||
export function PrintFinanzplanPage({ fmResults, lang, pageNum, totalPages, versionName }: SlideBase & { fmResults: FMResult[] }) {
|
||||
const de = lang === 'de'
|
||||
const byYear = new Map<number, FMResult[]>()
|
||||
for (const r of fmResults) {
|
||||
if (!byYear.has(r.year)) byYear.set(r.year, [])
|
||||
byYear.get(r.year)!.push(r)
|
||||
}
|
||||
const years = Array.from(byYear.entries()).sort(([a], [b]) => a - b).map(([year, rows]) => {
|
||||
const last = rows[rows.length - 1]
|
||||
const revenue = rows.reduce((s, r) => s + r.revenue_eur, 0)
|
||||
return {
|
||||
year,
|
||||
revenue,
|
||||
customers: last?.total_customers ?? 0,
|
||||
employees: last?.employees_count ?? 0,
|
||||
arr: last?.arr_eur ?? 0,
|
||||
mrr: last?.mrr_eur ?? 0,
|
||||
}
|
||||
})
|
||||
const fmt = (n: number) => {
|
||||
const abs = Math.abs(n)
|
||||
if (abs >= 1_000_000) return `${(n / 1_000_000).toLocaleString('de-DE', { maximumFractionDigits: 1 })}M`
|
||||
if (abs >= 1_000) return `${(n / 1_000).toLocaleString('de-DE', { maximumFractionDigits: 0 })}k`
|
||||
return n.toLocaleString('de-DE')
|
||||
}
|
||||
const maxRev = Math.max(1, ...years.map(y => y.revenue))
|
||||
return (
|
||||
<PrintPage title={de ? 'Finanzplan' : 'Financial Plan'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
|
||||
<SectionTitle subtitle={de ? '2026–2030 · Base Case · monatlich modelliert, jährlich aggregiert' : '2026–2030 · Base Case · monthly model, annual aggregation'}>
|
||||
{de ? 'Anhang · Finanzplan' : 'Appendix · Financial Plan'}
|
||||
</SectionTitle>
|
||||
{years.length === 0 ? (
|
||||
<div style={{ padding: '14px', border: `1px solid ${COLORS.border}`, borderRadius: '6px', background: '#f5f3ff', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
<p style={{ fontSize: '11px', color: COLORS.med, margin: 0, lineHeight: 1.55 }}>
|
||||
{de
|
||||
? 'Detaillierter Finanzplan (Umsatz, GuV, Liquidität, Personal, Kunden, Investitionen) ist im Investorenportal und im L-Bank Excel-Template verfügbar. Diese PDF-Version zeigt nur die annualisierten Kennzahlen aus dem Finanzmodell.'
|
||||
: 'Detailed financial plan (revenue, P&L, liquidity, personnel, customers, capex) is available in the investor portal and L-Bank Excel template. This PDF version shows only annualized KPIs from the financial model.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ marginBottom: '10px' }}>
|
||||
<PrintTable
|
||||
headers={[de ? 'Jahr' : 'Year', de ? 'Umsatz' : 'Revenue', 'ARR (Dec)', 'MRR (Dec)', de ? 'Kunden' : 'Customers', 'FTE']}
|
||||
rows={years.map(y => [
|
||||
<strong key="y">{y.year}</strong>,
|
||||
`${fmt(y.revenue)} EUR`,
|
||||
`${fmt(y.arr)} EUR`,
|
||||
`${fmt(y.mrr)} EUR`,
|
||||
y.customers.toString(),
|
||||
y.employees.toString(),
|
||||
])}
|
||||
colWidths={['10%', '20%', '20%', '20%', '15%', '15%']}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginTop: '6px' }}>
|
||||
<p style={{ fontSize: '9px', fontWeight: 700, color: COLORS.indigo, textTransform: 'uppercase', letterSpacing: '0.06em', margin: '0 0 6px' }}>{de ? 'Umsatzwachstum' : 'Revenue growth'}</p>
|
||||
{years.map(y => (
|
||||
<div key={y.year} style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '5px' }}>
|
||||
<span style={{ fontSize: '9px', color: COLORS.med, minWidth: '36px' }}>{y.year}</span>
|
||||
<div style={{ flex: 1, height: '12px', background: '#e0e7ff', borderRadius: '3px', overflow: 'hidden', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
<div style={{ height: '100%', width: `${(y.revenue / maxRev) * 100}%`, background: COLORS.indigo, WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }} />
|
||||
</div>
|
||||
<span style={{ fontSize: '9px', fontWeight: 700, color: COLORS.dark, minWidth: '70px', textAlign: 'right', fontFamily: 'ui-monospace, monospace' }}>{fmt(y.revenue)} EUR</span>
|
||||
{/* Bottom outcome block fills remaining vertical space and reads as a KPI tile */}
|
||||
<div style={{ marginTop: '4mm', paddingTop: '3mm', borderTop: `1px solid ${COLORS.slate200}` }}>
|
||||
<div style={{ fontSize: '7pt', color: COLORS.slate500, textTransform: 'uppercase', letterSpacing: '0.12em', fontWeight: 700, marginBottom: '2mm' }}>{de ? 'Outcome' : 'Outcome'}</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: kpiParts.length > 1 ? '1fr 1fr' : '1fr', gap: '3mm' }}>
|
||||
{kpiParts.map((part, j) => (
|
||||
<div key={j} style={{ fontSize: '14pt', fontWeight: 800, color: p.tone, lineHeight: 1.05, fontVariantNumeric: 'tabular-nums', letterSpacing: '-0.01em' }}>{part}</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<p style={{ fontSize: '8px', color: COLORS.light, marginTop: 'auto', fontStyle: 'italic' }}>
|
||||
{de ? '* Planzahlen aus dem internen Finanzmodell. SKR04-Kontenrahmen, monatliche Granularität. Detail-Tabs (GuV, Liquidität, Personalkosten, Kundenakquise) im Investor-Portal.' : '* Projections from internal financial model. SKR04 chart of accounts, monthly granularity. Detail tabs (P&L, liquidity, payroll, customer acquisition) in the investor portal.'}
|
||||
</p>
|
||||
</PrintPage>
|
||||
)
|
||||
})} />
|
||||
|
||||
<div style={{ marginTop: '5mm', flexShrink: 0 }}>
|
||||
<Callout tone="accent" label={de ? 'Vertriebs-Hypothese' : 'Sales hypothesis'}>
|
||||
{de
|
||||
? 'Maschinenbauer kaufen über Vertrauen + Referenzen. Phase 1: Beziehungen aufbauen. Phase 2: Multiplikatoren nutzen (Systemhäuser, IHK, VDMA). Phase 3: Inbound + Enterprise-Konten.'
|
||||
: 'Manufacturers buy via trust + references. Phase 1: build relationships. Phase 2: leverage multipliers (system houses, IHK, VDMA). Phase 3: inbound + enterprise accounts.'}
|
||||
</Callout>
|
||||
</div>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
const REG_DATA = {
|
||||
de: [
|
||||
{ name: 'DSGVO', full: 'EU 2016/679', deadline: 'Seit Mai 2018', fines: 'Bis 20 Mio. EUR / 4% Umsatz', reqs: ['VVT (Art. 30)', 'DSFA (Art. 35)', 'TOMs', 'Betroffenenrechte', 'AV-Verträge', 'DSB ab 20 MA', '72h-Meldepflicht'], help: ['Auto-VVT aus Unternehmensdaten', 'KI-gestützte DSFA', 'TOM-Generator', 'Self-Service Betroffenenportal', 'Audit-Trail'] },
|
||||
{ name: 'AI Act', full: 'EU 2024/1689', deadline: 'Aug 2025 / 2026 / 2027', fines: 'Bis 35 Mio. EUR / 7% Umsatz', reqs: ['Risikoklassifizierung (Art. 6)', 'Konformitätsbewertung Hochrisiko (Art. 43)', 'Tech. Doku (Art. 11-13)', 'Menschliche Aufsicht (Art. 14)', 'EU-Datenbank-Registrierung (Art. 49)', 'GPAI-Pflichten (Art. 51-56)', 'FRIA (Art. 27)'], help: ['Auto-Risikoklassifizierung', 'Konformitäts-Checklisten', 'Template-basierte Doku', 'Audit-Vorbereitung', 'Monitoring Rechtsänderungen'] },
|
||||
{ name: 'CRA', full: 'EU 2024/2847', deadline: 'Sep 2026 / Dez 2027', fines: 'Bis 15 Mio. EUR / 2,5% Umsatz', reqs: ['Security by Design', 'Schwachstellen-Mgmt über Lebenszyklus', 'SBOM für jedes Produkt', 'Kostenlose Security-Updates', '24h-Meldepflicht', 'Drittstellen-Bewertung kritisch', 'CE-Kennzeichnung Cyber'], help: ['Auto-SBOM aus Code-Repos', 'Kontinuierliches Vuln-Scanning (Trivy, Grype)', 'Security-Fixes via Cloud-LLM', 'CRA-Doku + Audit-Trail', 'Risikoanalysen Firmware'] },
|
||||
{ name: 'NIS2', full: 'EU 2022/2555', deadline: 'NIS2UmsuCG 2025/26', fines: 'Bis 10 Mio. EUR / 2% Umsatz', reqs: ['Risikomgmt-Maßnahmen (Art. 21)', '24h Frühwarnung, 72h Bericht (Art. 23)', 'Business Continuity', 'Supply-Chain-Security', 'Geschäftsleiterhaftung', 'BSI-Registrierung', 'Regelmäßige Audits'], help: ['Policy-Generator nach BSI-Grundschutz', 'Incident-Response-Pläne', 'Supply-Chain-Risikoanalyse', 'Audit-Doku', 'NIS2-Readiness-Assessment'] },
|
||||
],
|
||||
en: [
|
||||
{ name: 'GDPR', full: 'EU 2016/679', deadline: 'Since May 2018', fines: 'Up to EUR 20M / 4% revenue', reqs: ['RoPA (Art. 30)', 'DPIA (Art. 35)', 'TOMs', 'Data subject rights', 'DPAs', 'DPO from 20 emp.', '72h breach notification'], help: ['Auto-RoPA from company data', 'AI-powered DPIA', 'TOM generator', 'Self-service data subject portal', 'Audit trail'] },
|
||||
{ name: 'AI Act', full: 'EU 2024/1689', deadline: 'Aug 2025 / 2026 / 2027', fines: 'Up to EUR 35M / 7% revenue', reqs: ['Risk classification (Art. 6)', 'Conformity assessment high-risk (Art. 43)', 'Technical doc (Art. 11-13)', 'Human oversight (Art. 14)', 'EU database registration (Art. 49)', 'GPAI obligations (Art. 51-56)', 'FRIA (Art. 27)'], help: ['Auto risk classification', 'Conformity checklists', 'Template-based docs', 'Audit prep', 'Regulatory change monitoring'] },
|
||||
{ name: 'CRA', full: 'EU 2024/2847', deadline: 'Sep 2026 / Dec 2027', fines: 'Up to EUR 15M / 2.5% revenue', reqs: ['Security by design', 'Vuln mgmt across lifecycle', 'SBOM per product', 'Free security updates', '24h reporting', 'Third-party assessment critical', 'CE marking cyber'], help: ['Auto SBOM from code repos', 'Continuous vuln scanning (Trivy, Grype)', 'Security fixes via cloud LLM', 'CRA docs + audit trail', 'Firmware risk assessments'] },
|
||||
{ name: 'NIS2', full: 'EU 2022/2555', deadline: 'NIS2 Act 2025/26', fines: 'Up to EUR 10M / 2% revenue', reqs: ['Risk mgmt measures (Art. 21)', '24h early warning, 72h report (Art. 23)', 'Business continuity', 'Supply chain security', 'Management liability', 'BSI registration', 'Regular audits'], help: ['Policy generator BSI standards', 'Incident response plans', 'Supply chain risk analysis', 'Audit docs', 'NIS2 readiness assessment'] },
|
||||
],
|
||||
}
|
||||
/* ===== ANNEX REGULATORY ===== */
|
||||
|
||||
const PILLARS_DE = [
|
||||
{ t: 'DSGVO + AI Act', d: 'Datenschutz + KI-Regulierung greifen ineinander. Hochrisiko-KI-Systeme (Art. 6 AI Act) benötigen DSFA + Konformitätsbewertung. BreakPilot generiert beide automatisch.', laws: ['VO 2016/679 (DSGVO)', 'VO 2024/1689 (AI Act)', '§§ 9, 27 BDSG'] },
|
||||
{ t: 'NIS2 + Cyber Resilience Act', d: 'NIS2 schreibt Sicherheitsmaßnahmen für ~30k DE-Unternehmen vor. CRA addressiert Produkte mit digitalen Elementen. Beide brauchen kontinuierliches Vulnerability-Management.', laws: ['RL 2022/2555 (NIS2)', 'VO 2024/2847 (CRA)', '§§ 28 ff. BSIG'] },
|
||||
{ t: 'Maschinen-VO + ProdSG', d: 'Neue EU-Maschinenverordnung (ab 2027 verpflichtend) fordert Software-Risikobeurteilung schon auf Code-Ebene. Klassische CE-Verfahren reichen nicht mehr.', laws: ['VO 2023/1230 (MaschVO)', 'ProdSG 2021', 'EN ISO 12100'] },
|
||||
{ t: 'DORA + Branchenrecht', d: 'Finanzsektor: DORA. Gesundheit: MDR/IVDR. Öffentlicher Sektor: OZG. KRITIS: BSI-KritisV. BreakPilot deckt 380+ Regularien, branchenagnostisch.', laws: ['VO 2022/2554 (DORA)', 'VO 2017/745 (MDR)', 'BSI-KritisV'] },
|
||||
]
|
||||
const PILLARS_EN = [
|
||||
{ t: 'GDPR + AI Act', d: 'Data protection + AI regulation interlock. High-risk AI systems (AI Act Art. 6) require DPIA + conformity assessment. BreakPilot generates both automatically.', laws: ['Reg. 2016/679 (GDPR)', 'Reg. 2024/1689 (AI Act)', 'BDSG §§ 9, 27'] },
|
||||
{ t: 'NIS2 + Cyber Resilience Act', d: 'NIS2 mandates security measures for ~30k DE companies. CRA addresses products with digital elements. Both require continuous vulnerability management.', laws: ['Dir. 2022/2555 (NIS2)', 'Reg. 2024/2847 (CRA)', 'BSIG §§ 28 ff.'] },
|
||||
{ t: 'Machinery Reg. + ProdSG', d: 'New EU Machinery Regulation (mandatory from 2027) requires software risk assessment at code level. Traditional CE procedures are no longer sufficient.', laws: ['Reg. 2023/1230 (Mach. Reg.)', 'ProdSG 2021', 'EN ISO 12100'] },
|
||||
{ t: 'DORA + Industry Law', d: 'Finance: DORA. Health: MDR/IVDR. Public sector: OZG. KRITIS: BSI-KritisV. BreakPilot covers 380+ regulations, industry-agnostic.', laws: ['Reg. 2022/2554 (DORA)', 'Reg. 2017/745 (MDR)', 'BSI-KritisV'] },
|
||||
]
|
||||
|
||||
export function PrintRegulatoryPage({ lang, pageNum, totalPages, versionName }: SlideBase) {
|
||||
const de = lang === 'de'
|
||||
const regs = de ? REG_DATA.de : REG_DATA.en
|
||||
const pillars = de ? PILLARS_DE : PILLARS_EN
|
||||
const MONO = "'JetBrains Mono', ui-monospace, monospace"
|
||||
// Alternate tints — pillars 1 & 3 violet, 2 & 4 amber for visual rhythm.
|
||||
const tints = [
|
||||
{ dark: COLORS.violet700, mid: COLORS.violet600, light: COLORS.violet50, border: COLORS.violet300 },
|
||||
{ dark: COLORS.amber700, mid: COLORS.amber600, light: COLORS.amber50, border: '#f3d59a' },
|
||||
{ dark: COLORS.violet700, mid: COLORS.violet600, light: COLORS.violet50, border: COLORS.violet300 },
|
||||
{ dark: COLORS.amber700, mid: COLORS.amber600, light: COLORS.amber50, border: '#f3d59a' },
|
||||
]
|
||||
return (
|
||||
<PrintPage title={de ? 'Regulatorik' : 'Regulatory'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
|
||||
<SectionTitle subtitle={de ? 'Die vier Säulen der EU-Compliance für Maschinenbauer' : 'The four pillars of EU compliance for manufacturers'}>
|
||||
{de ? 'Anhang · Regulatorische Details' : 'Appendix · Regulatory Details'}
|
||||
</SectionTitle>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '8px', flex: 1 }}>
|
||||
{regs.map(r => (
|
||||
<div key={r.name} style={{ border: `1px solid ${COLORS.border}`, borderRadius: '6px', padding: '8px 10px', borderTop: `2px solid ${COLORS.indigo}`, display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: '2px' }}>
|
||||
<p style={{ fontSize: '12px', fontWeight: 800, color: COLORS.dark, margin: 0 }}>{r.name}</p>
|
||||
<p style={{ fontSize: '8px', color: COLORS.light, margin: 0, fontFamily: 'ui-monospace, monospace' }}>{r.full}</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '10px', fontSize: '8px', color: COLORS.med, margin: '0 0 6px' }}>
|
||||
<span>📅 {r.deadline}</span><span style={{ color: '#dc2626' }}>⚠ {r.fines}</span>
|
||||
</div>
|
||||
<div style={{ flex: 1, display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px' }}>
|
||||
<div>
|
||||
<p style={{ fontSize: '8px', fontWeight: 700, color: COLORS.indigo, textTransform: 'uppercase', letterSpacing: '0.04em', margin: '0 0 3px' }}>{de ? 'Anforderungen' : 'Requirements'}</p>
|
||||
<ul style={{ margin: 0, paddingLeft: '12px', fontSize: '8px', color: COLORS.med, lineHeight: 1.4 }}>
|
||||
{r.reqs.slice(0, 5).map(x => <li key={x}>{x}</li>)}
|
||||
</ul>
|
||||
<Page kicker="19" section={de ? 'ANHANG · REGULATORISCHE DETAILS' : 'APPENDIX · REGULATORY DETAILS'} title={de ? 'Vier Säulen der EU-Compliance für Maschinenbauer.' : 'Four pillars of EU compliance for manufacturers.'} subtitle={de ? 'Jede Säule deckt 4–6 verbindliche Regelwerke ab. BreakPilot mappt diese auf 25.000+ atomare Controls.' : 'Each pillar covers 4–6 binding regulations. BreakPilot maps these to 25,000+ atomic controls.'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
|
||||
|
||||
{/* Architectural row: 4 pillars side-by-side */}
|
||||
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '4mm', flex: 1, minHeight: 0, alignItems: 'stretch' }}>
|
||||
{pillars.map((p, i) => {
|
||||
const c = tints[i]
|
||||
return (
|
||||
<div key={i} style={{ display: 'flex', flexDirection: 'column', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
{/* CAPITAL (top) */}
|
||||
<div style={{ background: `linear-gradient(180deg, ${c.mid} 0%, ${c.dark} 100%)`, padding: '3mm 3mm 3.5mm', borderTopLeftRadius: '2pt', borderTopRightRadius: '2pt', margin: '0 -1.5mm', boxShadow: `0 2mm 2mm -1.5mm ${c.dark}55`, WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact', textAlign: 'center' }}>
|
||||
<div style={{ fontFamily: MONO, fontSize: '7pt', fontWeight: 700, color: '#ffffff', textTransform: 'uppercase', letterSpacing: '0.22em', opacity: 0.85 }}>{de ? 'SÄULE' : 'PILLAR'}</div>
|
||||
<div style={{ fontSize: '20pt', fontWeight: 800, color: '#ffffff', lineHeight: 1, marginTop: '1mm', fontVariantNumeric: 'tabular-nums', letterSpacing: '-0.01em' }}>{String(i + 1).padStart(2, '0')}</div>
|
||||
</div>
|
||||
{/* SHAFT (middle) */}
|
||||
<div style={{ background: c.light, borderLeft: `2mm solid ${c.mid}`, borderRight: `1px solid ${c.border}`, borderTop: `1px solid ${c.border}`, borderBottom: `1px solid ${c.border}`, padding: '3mm 3mm 3mm 2.5mm', flex: 1, display: 'flex', flexDirection: 'column', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
<div style={{ fontSize: '11.5pt', fontWeight: 700, color: COLORS.slate900, marginBottom: '2.5mm', lineHeight: 1.2, letterSpacing: '-0.005em' }}>{p.t}</div>
|
||||
<div style={{ fontSize: '8pt', color: COLORS.slate700, lineHeight: 1.55, flex: 1 }}>{p.d}</div>
|
||||
</div>
|
||||
{/* BASE (bottom) */}
|
||||
<div style={{ background: c.dark, margin: '0 -1.5mm', padding: '2mm 3mm', borderBottomLeftRadius: '2pt', borderBottomRightRadius: '2pt', fontFamily: MONO, fontSize: '6pt', color: '#ffffff', opacity: 0.95, lineHeight: 1.5, textAlign: 'center', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact', boxShadow: `0 -1mm 1.5mm -1mm ${c.dark}66 inset` }}>{p.laws.join(' · ')}</div>
|
||||
</div>
|
||||
<div>
|
||||
<p style={{ fontSize: '8px', fontWeight: 700, color: '#16a34a', textTransform: 'uppercase', letterSpacing: '0.04em', margin: '0 0 3px' }}>{de ? 'Wie wir helfen' : 'How we help'}</p>
|
||||
<ul style={{ margin: 0, paddingLeft: '12px', fontSize: '8px', color: COLORS.med, lineHeight: 1.4 }}>
|
||||
{r.help.slice(0, 5).map(x => <li key={x}>{x}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{/* Shared ground line — architectural reference */}
|
||||
<div style={{ marginTop: '2mm', height: '1px', background: COLORS.violet300, WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }} />
|
||||
<div style={{ marginTop: '0.6mm', height: '1px', background: COLORS.violet200, WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }} />
|
||||
</div>
|
||||
</PrintPage>
|
||||
|
||||
<div style={{ marginTop: '4mm', flexShrink: 0 }}>
|
||||
<Callout tone="caution" label={de ? 'Bußgeld-Risiko' : 'Penalty risk'}>
|
||||
{de
|
||||
? 'DSGVO: bis zu 4% Jahresumsatz · AI Act: bis zu 7% bei verbotener KI · NIS2: bis zu 2% · CRA: bis zu 2% · MDR/IVDR: produktspezifisch, Rückruf möglich. Kontinuierliche Compliance senkt das Risiko gegen Null.'
|
||||
: 'GDPR: up to 4% annual revenue · AI Act: up to 7% for prohibited AI · NIS2: up to 2% · CRA: up to 2% · MDR/IVDR: product-specific, recall possible. Continuous compliance reduces risk to near zero.'}
|
||||
</Callout>
|
||||
</div>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
const ARCH_NODES_DE = [
|
||||
{ id: 'certifai', title: 'CERTifAI', sub: 'GenAI Mandantenportal', tech: 'Rust · Dioxus · MongoDB · Keycloak · SearXNG · LangGraph', services: ['LiteLLM Dashboard', 'LibreChat + SSO', 'LangGraph Agents', 'MCP Hub'] },
|
||||
{ id: 'complai', title: 'COMPLAI', sub: 'Compliance & Audit', tech: 'Next.js 15 · FastAPI · Go/Gin · PostgreSQL · Qdrant', services: ['DSGVO / AI Act / NIS2 (70k+ Controls)', 'RAG Pipeline (75+ Quellen)', 'Control Pipeline (LLM)', 'MCP Client'] },
|
||||
{ id: 'scanner', title: 'Compliance Scanner', sub: 'Code-Sicherheit', tech: 'Rust · Axum · MongoDB · Semgrep · Gitleaks · Syft', services: ['SAST / SBOM / CVE Pipeline', 'KI-Triage (LLM-Filter)', 'KI-Pentest (autonom)', 'MCP Server'] },
|
||||
{ id: 'litellm', title: 'LiteLLM Proxy', sub: 'KI-Gateway & Guardrails', tech: 'OpenAI-API · Bearer Auth · Rate Limiting · PII-Filter', services: ['Token-Budget pro Mandant', 'PII Guardrails', 'SearXNG Web-Suche (anonym)', 'Namespace-Isolierung', 'Failover-Routing'] },
|
||||
{ id: 'llm', title: 'LLM Inferenz', sub: 'Lokale Sprachmodelle', tech: 'Qwen3-32B · Qwen3-Coder-30B · DeepSeek-R1-8B · Ollama', services: ['Vollständig lokal', 'Air-Gap-fähig', 'GPU-optimiert'] },
|
||||
{ id: 'embeddings', title: 'Embeddings', sub: 'Semantische Suche', tech: 'bge-m3 · Qdrant · Sentence-Transformers', services: ['RAG (75+ Quellen)', 'Multi-linguale Embeddings', '100% lokal'] },
|
||||
{ id: 'tools', title: 'KI-Tools', sub: 'Web-Suche & MCP', tech: 'SearXNG · MCP Protocol · Semgrep API · Gitleaks API', services: ['Anonymisierte EU-Websuche', 'MCP Tools (Audit/Code)', 'Kein US-Anbieter'] },
|
||||
]
|
||||
const ARCH_NODES_EN = [
|
||||
{ id: 'certifai', title: 'CERTifAI', sub: 'GenAI Tenant Portal', tech: 'Rust · Dioxus · MongoDB · Keycloak · SearXNG · LangGraph', services: ['LiteLLM Dashboard', 'LibreChat + SSO', 'LangGraph Agents', 'MCP Hub'] },
|
||||
{ id: 'complai', title: 'COMPLAI', sub: 'Compliance & Audit', tech: 'Next.js 15 · FastAPI · Go/Gin · PostgreSQL · Qdrant', services: ['GDPR / AI Act / NIS2 (70k+ controls)', 'RAG Pipeline (75+ sources)', 'Control Pipeline (LLM)', 'MCP Client'] },
|
||||
{ id: 'scanner', title: 'Compliance Scanner', sub: 'Code Security', tech: 'Rust · Axum · MongoDB · Semgrep · Gitleaks · Syft', services: ['SAST / SBOM / CVE pipeline', 'AI triage (LLM filter)', 'AI pentest (autonomous)', 'MCP Server'] },
|
||||
{ id: 'litellm', title: 'LiteLLM Proxy', sub: 'AI Gateway & Guardrails', tech: 'OpenAI API · Bearer Auth · Rate Limiting · PII filter', services: ['Token budget per tenant', 'PII guardrails', 'SearXNG web search (anon)', 'Namespace isolation', 'Failover routing'] },
|
||||
{ id: 'llm', title: 'LLM Inference', sub: 'Local Language Models', tech: 'Qwen3-32B · Qwen3-Coder-30B · DeepSeek-R1-8B · Ollama', services: ['Fully local', 'Air-gap capable', 'GPU optimized'] },
|
||||
{ id: 'embeddings', title: 'Embeddings', sub: 'Semantic Search', tech: 'bge-m3 · Qdrant · Sentence-Transformers', services: ['RAG (75+ sources)', 'Multi-lingual embeddings', '100% local'] },
|
||||
{ id: 'tools', title: 'AI Tools', sub: 'Web Search & MCP', tech: 'SearXNG · MCP Protocol · Semgrep API · Gitleaks API', services: ['Anonymized EU web search', 'MCP tools (audit/code)', 'No US providers'] },
|
||||
]
|
||||
/* ===== ANNEX ARCHITECTURE ===== */
|
||||
|
||||
export function PrintArchitecturePage({ lang, pageNum, totalPages, versionName }: SlideBase) {
|
||||
const de = lang === 'de'
|
||||
const nodes = de ? ARCH_NODES_DE : ARCH_NODES_EN
|
||||
return (
|
||||
<PrintPage title={de ? 'Architektur' : 'Architecture'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
|
||||
<SectionTitle subtitle={de ? 'BreakPilot · CERTifAI · Compliance Scanner — verbunden über LiteLLM Proxy, alle EU-gehostet' : 'BreakPilot · CERTifAI · Compliance Scanner — connected via LiteLLM proxy, all EU-hosted'}>
|
||||
{de ? 'Anhang · Systemarchitektur' : 'Appendix · System Architecture'}
|
||||
</SectionTitle>
|
||||
<div style={{ flex: 1, display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '6px' }}>
|
||||
{nodes.map(n => (
|
||||
<div key={n.id} style={{ border: `1px solid ${COLORS.border}`, borderRadius: '6px', padding: '6px 9px', borderLeft: `3px solid ${COLORS.indigo}` }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: '3px' }}>
|
||||
<p style={{ fontSize: '11px', fontWeight: 700, color: COLORS.dark, margin: 0 }}>{n.title}</p>
|
||||
<p style={{ fontSize: '8px', color: COLORS.light, margin: 0 }}>{n.sub}</p>
|
||||
</div>
|
||||
<p style={{ fontSize: '8px', color: COLORS.indigo, fontFamily: 'ui-monospace, monospace', margin: '0 0 4px', lineHeight: 1.35 }}>{n.tech}</p>
|
||||
<ul style={{ margin: 0, paddingLeft: '12px', fontSize: '8px', color: COLORS.med, lineHeight: 1.4 }}>
|
||||
{n.services.map(s => <li key={s}>{s}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</PrintPage>
|
||||
<Page kicker="20" section={de ? 'ANHANG · SYSTEMARCHITEKTUR' : 'APPENDIX · SYSTEM ARCHITECTURE'} title={de ? 'Drei Produkt-Domänen. Ein LiteLLM-Gateway. Lokale Inferenz.' : 'Three product domains. One LiteLLM gateway. Local inference.'} subtitle={de ? 'BreakPilot · CERTifAI · Compliance Scanner, über LiteLLM-Proxy mit lokalen Inferenz-Knoten verbunden. 100% EU, kein US-Anbieter.' : 'BreakPilot · CERTifAI · Compliance Scanner, connected via LiteLLM proxy to local inference nodes. 100% EU, no US providers.'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
|
||||
<ArchitectureDiagram
|
||||
lang={lang}
|
||||
product={[
|
||||
{ kicker: 'GENAI', title: 'CERTifAI', subtitle: de ? 'GenAI Mandantenportal' : 'GenAI Tenant Portal', tech: 'Rust · Dioxus · MongoDB · Keycloak · SearXNG · LangGraph', services: ['LiteLLM Dashboard', 'LibreChat + SSO', 'LangGraph Agents', 'MCP Hub'] },
|
||||
{ kicker: 'COMPLIANCE', title: 'COMPLAI', subtitle: de ? 'Compliance & Audit' : 'Compliance & Audit', tech: 'Next.js 15 · FastAPI · Go/Gin · PostgreSQL · Qdrant · Valkey', services: [de ? 'DSGVO/AI Act/NIS2 (25k+ Controls)' : 'GDPR/AI Act/NIS2 (25k+ controls)', de ? 'RAG (380+ Quellen)' : 'RAG (380+ sources)', de ? 'Control Pipeline (LLM)' : 'Control pipeline (LLM)', 'MCP Client'] },
|
||||
{ kicker: 'SECURITY', title: 'Compliance Scanner', subtitle: de ? 'Code-Sicherheit' : 'Code Security', tech: 'Rust · Axum · MongoDB · Semgrep · Gitleaks · Syft', services: ['SAST · SBOM · CVE Pipeline', de ? 'KI-Triage (False Positives)' : 'AI Triage (false positives)', de ? 'KI-Pentest (autonom)' : 'AI Pentest (autonomous)', 'MCP Server'] },
|
||||
]}
|
||||
proxy={{
|
||||
title: 'LiteLLM Proxy',
|
||||
subtitle: de ? 'KI-Gateway · Bearer-Auth · Rate-Limiting · PII-Filter · Spend-Tracking' : 'AI gateway · bearer auth · rate limiting · PII filter · spend tracking',
|
||||
features: [
|
||||
de ? 'Token-Budget pro Mandant' : 'Per-tenant token budget',
|
||||
de ? 'PII-Guardrails alle Anfragen' : 'PII guardrails on all requests',
|
||||
de ? 'Anonyme EU-Web-Suche (SearXNG)' : 'Anonymous EU web search (SearXNG)',
|
||||
de ? 'Namespace-Isolierung pro API-Key' : 'Namespace isolation per API key',
|
||||
de ? 'Failover-Routing zwischen Modellen' : 'Failover routing between models',
|
||||
],
|
||||
}}
|
||||
inference={[
|
||||
{ title: de ? 'LLM Inferenz' : 'LLM Inference', subtitle: de ? 'Lokale Sprachmodelle' : 'Local language models', tech: 'Qwen3-32B · Qwen3-Coder-30B · DeepSeek-R1-8B · Ollama', desc: de ? 'Vollständig lokal, air-gap fähig, GPU-optimiert. Daten verlassen nie den Server.' : 'Fully local, air-gap capable, GPU-optimized. Data never leaves the server.' },
|
||||
{ title: 'Embeddings', subtitle: de ? 'Semantische Suche' : 'Semantic search', tech: 'bge-m3 · Qdrant Vector DB · Sentence-Transformers', desc: de ? 'RAG mit 380+ Rechtsquellen indexiert, multilinguale Einbettungen, lokal.' : 'RAG with 380+ legal sources indexed, multilingual embeddings, local.' },
|
||||
{ title: de ? 'KI-Tools' : 'AI Tools', subtitle: de ? 'Web-Suche & MCP' : 'Web search & MCP', tech: 'SearXNG · MCP Protocol · Semgrep API · Gitleaks API', desc: de ? 'Anonymisierte EU-Web-Suche, MCP-Integration für Audit-Dokumente und Code-Findings.' : 'Anonymized EU web search, MCP integration for audit docs and code findings.' },
|
||||
]}
|
||||
/>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
/* ===== ANNEX ENGINEERING ===== */
|
||||
|
||||
export function PrintEngineeringPage({ lang, pageNum, totalPages, versionName }: SlideBase) {
|
||||
const de = lang === 'de'
|
||||
const stats = [
|
||||
{ v: '500K+', l: de ? 'Zeilen Code' : 'Lines of code', s: 'Go · Python · TypeScript' },
|
||||
{ v: '385', l: de ? 'Dokumente im RAG' : 'Docs in RAG', s: 'EU · DACH · Frameworks · Urteile' },
|
||||
{ v: '25K+', l: de ? 'Compliance Controls' : 'Compliance Controls', s: '6 Pipeline-Versionen' },
|
||||
]
|
||||
const langs = [
|
||||
{ lang: 'TypeScript / TSX', pct: 49, loc: '235K', color: '#3b82f6' },
|
||||
{ lang: 'Python', pct: 28, loc: '133K', color: '#eab308' },
|
||||
{ lang: 'Go', pct: 23, loc: '113K', color: '#06b6d4' },
|
||||
]
|
||||
const devops = [
|
||||
{ l: 'Gitea + Actions', d: de ? 'Self-hosted Git + CI/CD · Lint → Tests → Image-Build' : 'Self-hosted Git + CI/CD · Lint → Tests → Image build' },
|
||||
{ l: 'orca', d: de ? 'Single-Binary Orchestrator (Rust) · Webhook-Deploy · Auto-TLS' : 'Single-binary orchestrator (Rust) · Webhook deploys · Auto-TLS' },
|
||||
{ l: 'Private Registry', d: 'registry.meghsakha.com · Signed images · Per-commit tags' },
|
||||
{ l: 'DevSecOps', d: 'Semgrep · Trivy · Gitleaks · CycloneDX SBOM' },
|
||||
{ l: 'Infisical', d: de ? 'Secrets Mgmt · Rotation · RBAC · End-to-End-verschlüsselt' : 'Secrets mgmt · Rotation · RBAC · End-to-end encrypted' },
|
||||
{ l: de ? 'EU-Cloud Infrastruktur' : 'EU Cloud Infrastructure', d: 'Hetzner · SysEleven (BSI) · PostgreSQL · Qdrant' },
|
||||
]
|
||||
return (
|
||||
<PrintPage title={de ? 'Engineering' : 'Engineering'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
|
||||
<SectionTitle subtitle={de ? '500K+ Zeilen Code · 45 Container · 100% Self-Hosted in EU' : '500K+ lines of code · 45 containers · 100% self-hosted in EU'}>
|
||||
{de ? 'Anhang · Engineering Deep Dive' : 'Appendix · Engineering Deep Dive'}
|
||||
</SectionTitle>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '8px', marginBottom: '10px' }}>
|
||||
{stats.map(s => (
|
||||
<div key={s.l} style={{ background: COLORS.indigoLight, padding: '10px', borderRadius: '6px', textAlign: 'center', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
<p style={{ fontSize: '22px', fontWeight: 800, color: COLORS.indigo, margin: 0, lineHeight: 1 }}>{s.v}</p>
|
||||
<p style={{ fontSize: '10px', fontWeight: 700, color: COLORS.dark, margin: '4px 0 2px' }}>{s.l}</p>
|
||||
<p style={{ fontSize: '8px', color: COLORS.light, margin: 0 }}>{s.s}</p>
|
||||
<Page kicker="21" section={de ? 'ANHANG · ENGINEERING' : 'APPENDIX · ENGINEERING'} title={de ? '500K+ Zeilen Code. 45 Container. 100% Self-Hosted.' : '500K+ lines of code. 45 containers. 100% self-hosted.'} subtitle={de ? 'Polyglott (Go/Python/TypeScript/Rust), Microservice-Architektur, vollständig containerisiert. Built for sovereignty.' : 'Polyglot (Go/Python/TypeScript/Rust), microservice architecture, fully containerized. Built for sovereignty.'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '4mm', padding: '4mm 0', borderTop: `1px solid ${COLORS.slate200}`, borderBottom: `1px solid ${COLORS.slate200}`, marginBottom: '5mm' }}>
|
||||
{[
|
||||
{ n: '500K+', l: 'Lines of Code' },
|
||||
{ n: '45', l: de ? 'Container' : 'Containers' },
|
||||
{ n: '4', l: de ? 'Sprachen' : 'Languages' },
|
||||
{ n: '100%', l: 'Self-Hosted' },
|
||||
].map((k, i) => (
|
||||
<div key={i}>
|
||||
<div style={{ fontSize: '24pt', fontWeight: 800, color: COLORS.indigo600, lineHeight: 1, fontVariantNumeric: 'tabular-nums' }}>{k.n}</div>
|
||||
<div style={{ fontSize: '7.5pt', color: COLORS.slate500, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.08em', marginTop: '1mm' }}>{k.l}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1.4fr', gap: '12px', flex: 1 }}>
|
||||
|
||||
<div style={{ flex: 1, minHeight: 0, display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8mm' }}>
|
||||
<div>
|
||||
<p style={{ fontSize: '9px', fontWeight: 700, color: COLORS.indigo, textTransform: 'uppercase', letterSpacing: '0.06em', margin: '0 0 6px' }}>{de ? 'Sprachen-Mix' : 'Language Mix'}</p>
|
||||
<div style={{ display: 'flex', height: '10px', borderRadius: '4px', overflow: 'hidden', marginBottom: '8px' }}>
|
||||
{langs.map(l => <div key={l.lang} style={{ width: `${l.pct}%`, background: l.color, WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }} />)}
|
||||
</div>
|
||||
{langs.map(l => (
|
||||
<div key={l.lang} style={{ display: 'flex', justifyContent: 'space-between', fontSize: '9px', color: COLORS.med, padding: '3px 0' }}>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<span style={{ width: '7px', height: '7px', borderRadius: '99px', background: l.color, WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }} />
|
||||
{l.lang}
|
||||
</span>
|
||||
<span><span style={{ fontFamily: 'ui-monospace, monospace', color: COLORS.light, marginRight: '8px' }}>{l.loc}</span><strong style={{ color: COLORS.dark }}>{l.pct}%</strong></span>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ fontSize: '8pt', fontWeight: 700, color: COLORS.slate500, textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '2mm' }}>{de ? 'Tech-Stack pro Schicht' : 'Tech stack per layer'}</div>
|
||||
<DataTable
|
||||
cols={[
|
||||
{ header: de ? 'Schicht' : 'Layer', width: '28%' },
|
||||
{ header: 'Stack' },
|
||||
]}
|
||||
rows={[
|
||||
[de ? 'Frontend' : 'Frontend', 'Next.js 15 · React 19 · Tailwind · Framer Motion · Dioxus (Rust)'],
|
||||
[de ? 'Backend (HTTP)' : 'Backend (HTTP)', 'Go/Gin · Python/FastAPI · Rust/Axum'],
|
||||
[de ? 'Storage' : 'Storage', 'PostgreSQL 16 · MongoDB · Qdrant (vector) · Valkey (cache)'],
|
||||
[de ? 'KI / RAG' : 'AI / RAG', 'LiteLLM · Qwen3 · DeepSeek · Sentence-Transformers · LangGraph'],
|
||||
[de ? 'Code-Scanning' : 'Code scanning', 'Semgrep · Gitleaks · Syft · Trivy · CycloneDX'],
|
||||
[de ? 'Auth & SSO' : 'Auth & SSO', 'Keycloak · OIDC · OPA (policies)'],
|
||||
[de ? 'Kommunikation' : 'Communication', 'Matrix (chat) · Jitsi (video) · Mailpit'],
|
||||
[de ? 'DevOps' : 'DevOps', 'Gitea · Woodpecker CI · Vault · Orca · Docker Compose'],
|
||||
]}
|
||||
dense
|
||||
highlightFirstCol
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p style={{ fontSize: '9px', fontWeight: 700, color: COLORS.indigo, textTransform: 'uppercase', letterSpacing: '0.06em', margin: '0 0 6px' }}>{de ? 'DevOps & Toolchain' : 'DevOps & Toolchain'}</p>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '6px' }}>
|
||||
{devops.map(t => (
|
||||
<div key={t.l} style={{ border: `1px solid ${COLORS.border}`, borderRadius: '4px', padding: '6px 8px' }}>
|
||||
<p style={{ fontSize: '10px', fontWeight: 700, color: COLORS.dark, margin: '0 0 2px' }}>{t.l}</p>
|
||||
<p style={{ fontSize: '8px', color: COLORS.med, margin: 0, lineHeight: 1.35 }}>{t.d}</p>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ fontSize: '8pt', fontWeight: 700, color: COLORS.slate500, textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '2mm' }}>{de ? 'Engineering-Prinzipien' : 'Engineering principles'}</div>
|
||||
<Bullets dense items={de ? [
|
||||
'AGENTS.md · CLAUDE.md · architektur-getrieben',
|
||||
'500-LOC Cap pro Datei, keine Mega-Files',
|
||||
'Microservices: jeder Service in eigenem Container',
|
||||
'Service-Layer + Repository-Pattern (Go), Routes/Services/Repos (Python)',
|
||||
'Pre-Push Checks PFLICHT: tsc + lint + build (TS), ruff + mypy + pytest (Py), gofmt + vet + lint + test (Go)',
|
||||
'Open Source nur mit MIT/Apache-2.0/BSD/ISC/MPL-2.0/LGPL, keine GPL/AGPL',
|
||||
'Self-Hosted CI (Gitea Actions, Woodpecker), keine US-Cloud-CI',
|
||||
'Secret-Management über HashiCorp Vault',
|
||||
'Tests sind Pflicht bei jeder Code-Änderung',
|
||||
'BSI-Cloud DE, EU-Hosting, kein US-SaaS in Source Code',
|
||||
] : [
|
||||
'AGENTS.md · CLAUDE.md · architecture-driven',
|
||||
'500-LOC cap per file, no mega-files',
|
||||
'Microservices: each service in its own container',
|
||||
'Service layer + repository pattern (Go), routes/services/repos (Python)',
|
||||
'Pre-push checks MANDATORY: tsc + lint + build (TS), ruff + mypy + pytest (Py), gofmt + vet + lint + test (Go)',
|
||||
'Open source only with MIT/Apache-2.0/BSD/ISC/MPL-2.0/LGPL, no GPL/AGPL',
|
||||
'Self-hosted CI (Gitea Actions, Woodpecker), no US cloud CI',
|
||||
'Secret management via HashiCorp Vault',
|
||||
'Tests required on every code change',
|
||||
'BSI cloud DE, EU hosting, no US SaaS in source code',
|
||||
]} />
|
||||
|
||||
<div style={{ marginTop: '4mm' }}>
|
||||
<Callout tone="accent" label={de ? 'Engineering-Velocity' : 'Engineering velocity'}>
|
||||
{de
|
||||
? 'Seit Jan 2026: 500.000+ LoC in 4 Monaten. Mit Series-A-Funding: 2× Senior Engineers, Velocity verdoppelt sich. Architektur skaliert auf 10+ Engineers ohne Refactor.'
|
||||
: 'Since Jan 2026: 500,000+ LoC in 4 months. With Series A funding: 2× senior engineers, velocity doubles. Architecture scales to 10+ engineers without refactor.'}
|
||||
</Callout>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p style={{ fontSize: '8px', color: COLORS.light, marginTop: '8px', textAlign: 'center' }}>
|
||||
{de ? '100% EU-Cloud · Hetzner + SysEleven (BSI) · Keine US-Anbieter · Volle Datenkontrolle' : '100% EU Cloud · Hetzner + SysEleven (BSI) · No US providers · Full data control'}
|
||||
</p>
|
||||
</PrintPage>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
const PIPELINE_STEPS = {
|
||||
de: [
|
||||
{ t: '1. Dokument-Ingestion', d: '380+ Rechtsquellen (EU/DACH). Strukturelles Chunking an Artikel-Grenzen. Lizenz-Klassifikation. Geschützte Quellen werden reformuliert.' },
|
||||
{ t: '2. Control-Extraktion', d: 'LLM extrahiert Pflichten aus jedem Textabschnitt. 6 Pipeline-Versionen. ~97k Pflichten identifiziert. Atomic Control Composition.' },
|
||||
{ t: '3. Deduplizierung', d: '97k Controls → 70k+ nach Dedup. Embedding-basierte Similarity. Cross-Regulation-Harmonisierung. Master Controls als Single Source of Truth.' },
|
||||
{ t: '4. Hybrid Search & Beratung', d: 'Vektor + Keyword über alle Quellen gleichzeitig. Cross-Encoder Re-Ranking. Antworten mit Quellen-Attribution (Artikel + Absatz).' },
|
||||
],
|
||||
en: [
|
||||
{ t: '1. Document Ingestion', d: '380+ legal sources (EU/DACH). Structural chunking at article boundaries. License classification. Protected standards are reformulated.' },
|
||||
{ t: '2. Control Extraction', d: 'LLM extracts obligations from each section. 6 pipeline versions. ~97k duties identified. Atomic control composition.' },
|
||||
{ t: '3. Deduplication', d: '97k controls → 70k+ after dedup. Embedding-based similarity. Cross-regulation harmonization. Master controls as single source of truth.' },
|
||||
{ t: '4. Hybrid Search & Advisory', d: 'Vector + keyword across all sources simultaneously. Cross-encoder re-ranking. Answers with source attribution (article + paragraph).' },
|
||||
],
|
||||
}
|
||||
/* ===== ANNEX AI PIPELINE ===== */
|
||||
|
||||
export function PrintAIPipelinePage({ lang, pageNum, totalPages, versionName }: SlideBase) {
|
||||
const de = lang === 'de'
|
||||
const stats = [
|
||||
{ v: '380+', l: de ? 'Rechtsquellen' : 'Legal sources' },
|
||||
{ v: '70k+', l: de ? 'Unique Controls' : 'Unique controls' },
|
||||
{ v: '97k+', l: de ? 'Extrahierte Pflichten' : 'Extracted obligations' },
|
||||
{ v: '6', l: de ? 'Pipeline-Versionen' : 'Pipeline versions' },
|
||||
]
|
||||
const agents = de
|
||||
? ['UCCA: Policy Engine (45 Regeln) + Eskalation E0–E3', 'Pflichten-Engine: Multi-Regulation (NIS2, DSGVO, AI Act, CRA, ...)', 'Compliance-Berater: Legal RAG + LLM Chatbot, 75+ Quellen', 'Dokument-Generator: AGB, DSE, AVV, DSFA, FRIA, BV …', 'DSFA-Agent: Art. 35 DSGVO, 16 Bundesländer-Leitlinien', 'Control-Pipeline: Auto-Extraktion aus neuen Rechtsquellen']
|
||||
: ['UCCA: Policy engine (45 rules) + escalation E0–E3', 'Obligations Engine: Multi-regulation (NIS2, GDPR, AI Act, CRA, ...)', 'Compliance Advisor: Legal RAG + LLM chatbot, 75+ sources', 'Document Generator: T&C, Privacy, DPA, DPIA, FRIA, Works Agreement …', 'DPIA Agent: Art. 35 GDPR, 16 federal state guidelines', 'Control Pipeline: Auto-extraction from new legal sources']
|
||||
const steps = de ? PIPELINE_STEPS.de : PIPELINE_STEPS.en
|
||||
return (
|
||||
<PrintPage title={de ? 'KI-Pipeline' : 'AI Pipeline'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
|
||||
<SectionTitle subtitle={de ? 'RAG · Multi-Agent-System · Document Intelligence · 100% EU-Hosting' : 'RAG · Multi-agent system · Document intelligence · 100% EU hosting'}>
|
||||
{de ? 'Anhang · KI-Pipeline Deep Dive' : 'Appendix · AI Pipeline Deep Dive'}
|
||||
</SectionTitle>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '8px', marginBottom: '10px' }}>
|
||||
{stats.map(s => (
|
||||
<div key={s.l} style={{ background: COLORS.indigoLight, padding: '8px', borderRadius: '6px', textAlign: 'center', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
<p style={{ fontSize: '18px', fontWeight: 800, color: COLORS.indigo, margin: 0 }}>{s.v}</p>
|
||||
<p style={{ fontSize: '9px', color: COLORS.med, margin: '2px 0 0', textTransform: 'uppercase', letterSpacing: '0.04em' }}>{s.l}</p>
|
||||
<Page kicker="22" section={de ? 'ANHANG · KI-PIPELINE' : 'APPENDIX · AI PIPELINE'} title={de ? 'RAG · Multi-Agent-System · Document Intelligence · Quality Assurance.' : 'RAG · Multi-Agent System · Document Intelligence · Quality Assurance.'} subtitle={de ? 'Vier Stufen, von Rohdokument zu auditierbarem Control. Vollständig EU-lokal, kein US-LLM, kein Vendor-Lock-in.' : 'Four stages, from raw document to auditable control. Fully EU-local, no US LLM, no vendor lock-in.'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
|
||||
|
||||
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', gap: '4mm' }}>
|
||||
{/* Pipeline flow */}
|
||||
<div>
|
||||
<div style={{ fontSize: '7.5pt', fontWeight: 700, color: COLORS.slate500, textTransform: 'uppercase', letterSpacing: '0.12em', marginBottom: '2mm' }}>{de ? 'Pipeline-Fluss' : 'Pipeline flow'}</div>
|
||||
<PipelineFlow
|
||||
stages={[
|
||||
{ n: '01', t: 'Ingestion', d: de ? 'Rohdokumente (PDF, HTML, XML, Word) → Pass 0a OCR → Pass 0b Strukturierung. Markdown + Metadaten.' : 'Raw docs (PDF, HTML, XML, Word) → Pass 0a OCR → Pass 0b structuring. Markdown + metadata.', kpi: '385 docs' },
|
||||
{ n: '02', t: 'Chunking + Embed', d: de ? 'Semantisches Chunking, Sentence-Transformers (bge-m3), Qdrant Vector DB, Multi-Index.' : 'Semantic chunking, sentence-transformers (bge-m3), Qdrant vector DB, multi-index.', kpi: '~280k chunks' },
|
||||
{ n: '03', t: 'Control Extraction', d: de ? 'BatchDedup → LLM-Extraktor (Qwen3-32B) → Strukturierte Controls mit Quellenangabe + Confidence.' : 'BatchDedup → LLM extractor (Qwen3-32B) → structured controls with source + confidence.', kpi: '25k+ controls' },
|
||||
{ n: '04', t: 'Quality Assurance', d: de ? 'BQAS (Batch Quality Assessment System): Cross-Validation, Inkonsistenz-Detection, Audit-Sampling.' : 'BQAS (Batch Quality Assessment System): cross-validation, inconsistency detection, audit sampling.', kpi: '>99% precision' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Agent system */}
|
||||
<div style={{ flex: 1, minHeight: 0, display: 'grid', gridTemplateColumns: '1.4fr 1fr', gap: '6mm' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: '7.5pt', fontWeight: 700, color: COLORS.slate500, textTransform: 'uppercase', letterSpacing: '0.12em', marginBottom: '2mm' }}>{de ? 'Multi-Agent-System (LangGraph)' : 'Multi-Agent System (LangGraph)'}</div>
|
||||
<DataTable
|
||||
cols={[
|
||||
{ header: 'Agent', width: '28%' },
|
||||
{ header: de ? 'Verantwortung' : 'Responsibility' },
|
||||
{ header: 'Model', width: '24%' },
|
||||
]}
|
||||
rows={[
|
||||
['Researcher', de ? 'Recherchiert in RAG + SearXNG, sammelt Quellen' : 'Searches RAG + SearXNG, gathers sources', 'Qwen3-32B'],
|
||||
['Drafter', de ? 'Erstellt VVT, TOMs, DSFA, Policies' : 'Drafts RoPA, TOMs, DPIA, policies', 'Qwen3-32B'],
|
||||
['Reviewer', de ? 'Prüft auf Vollständigkeit + Compliance' : 'Checks completeness + compliance', 'DeepSeek-R1-8B'],
|
||||
['Coder', de ? 'Auto-Fix für Code-Findings (SAST/DAST)' : 'Auto-fix for code findings (SAST/DAST)', 'Qwen3-Coder-30B'],
|
||||
['Pentester', de ? 'Autonome Angriffsketten + Exploitability' : 'Autonomous attack chains + exploitability', 'Qwen3-32B'],
|
||||
['Supervisor', de ? 'Orchestriert, prüft Konsistenz, eskaliert' : 'Orchestrates, checks consistency, escalates', 'DeepSeek-R1-8B'],
|
||||
]}
|
||||
dense
|
||||
highlightFirstCol
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1.3fr 1fr', gap: '10px', flex: 1 }}>
|
||||
<div>
|
||||
<p style={{ fontSize: '9px', fontWeight: 700, color: COLORS.indigo, textTransform: 'uppercase', letterSpacing: '0.06em', margin: '0 0 6px' }}>{de ? 'RAG-Pipeline (4 Stufen)' : 'RAG Pipeline (4 stages)'}</p>
|
||||
{steps.map(s => (
|
||||
<div key={s.t} style={{ borderLeft: `2px solid ${COLORS.indigo}`, paddingLeft: '8px', marginBottom: '6px' }}>
|
||||
<p style={{ fontSize: '10px', fontWeight: 700, color: COLORS.dark, margin: '0 0 2px' }}>{s.t}</p>
|
||||
<p style={{ fontSize: '9px', color: COLORS.med, margin: 0, lineHeight: 1.45 }}>{s.d}</p>
|
||||
|
||||
<div>
|
||||
<div style={{ fontSize: '7.5pt', fontWeight: 700, color: COLORS.slate500, textTransform: 'uppercase', letterSpacing: '0.12em', marginBottom: '2mm' }}>{de ? 'Qualitäts-Kennzahlen' : 'Quality metrics'}</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0' }}>
|
||||
{[
|
||||
{ l: de ? 'Precision (Controls)' : 'Precision (controls)', v: '>99%' },
|
||||
{ l: 'Recall (RAG)', v: '~94%' },
|
||||
{ l: de ? 'Audit-Sampling Rate' : 'Audit sampling rate', v: '5%' },
|
||||
{ l: de ? 'False-Positive Rate' : 'False positive rate', v: '<2%' },
|
||||
{ l: de ? 'Mean Latency (RAG)' : 'Mean latency (RAG)', v: '~180ms' },
|
||||
{ l: de ? 'Throughput Control-Pipeline' : 'Throughput control pipeline', v: '~1.2k/min' },
|
||||
].map((s, i) => (
|
||||
<div key={i} style={{ display: 'flex', justifyContent: 'space-between', padding: '2mm 0', borderBottom: `1px solid ${COLORS.slate100}`, fontSize: '8.5pt' }}>
|
||||
<span style={{ color: COLORS.slate700 }}>{s.l}</span>
|
||||
<span style={{ fontWeight: 700, color: COLORS.indigo600, fontVariantNumeric: 'tabular-nums' }}>{s.v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<p style={{ fontSize: '9px', fontWeight: 700, color: COLORS.indigo, textTransform: 'uppercase', letterSpacing: '0.06em', margin: '0 0 6px' }}>{de ? 'Compliance-Engines' : 'Compliance Engines'}</p>
|
||||
<ul style={{ margin: 0, paddingLeft: 0, listStyle: 'none' }}>
|
||||
{agents.map(a => (
|
||||
<li key={a} style={{ fontSize: '9px', color: COLORS.med, padding: '4px 0 4px 14px', position: 'relative', lineHeight: 1.45, borderBottom: `1px solid ${COLORS.border}` }}>
|
||||
<span style={{ position: 'absolute', left: 0, top: '8px', width: '5px', height: '5px', borderRadius: '99px', background: COLORS.indigo, WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }} />
|
||||
{a}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p style={{ fontSize: '8px', color: COLORS.light, marginTop: '8px', fontStyle: 'italic' }}>
|
||||
{de ? 'Wahrheit = Regeln + Evidenz · LLM = Übersetzer. Qdrant · BGE-M3 · MinIO. 100% EU-Cloud.' : 'Truth = Rules + Evidence · LLM = Translator. Qdrant · BGE-M3 · MinIO. 100% EU Cloud.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PrintPage>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
const RISKS_DE = [
|
||||
{ t: 'KI-Commoditisierung', sev: 'Hoch', sevColor: '#dc2626', timeline: '3-5 J.', d: 'LLMs senken Eintrittsbarrieren — Control-Generierung, DSFA-Erstellung, Policy-Templates werden Commodity.', m: 'Wir konkurrieren auf Layer 2-6: Integration, Auditierbarkeit, Workflows, EU-Hosting. KI ist Multiplikator, nicht Produkt.' },
|
||||
{ t: 'US-Plattform-Expansion', sev: 'Mittel', sevColor: '#d97706', timeline: '2-4 J.', d: 'Microsoft Purview, Vanta oder Drata expandieren mit lokalisiertem EU-Angebot.', m: 'Struktureller Vorteil: 100% EU-Infrastruktur, kein US-SaaS, Betriebsrat-Fähigkeit. CLOUD Act ist Ausschlusskriterium.' },
|
||||
{ t: 'Team-Risiko / Key-Person', sev: 'Mittel', sevColor: '#d97706', timeline: 'Jahr 1-2', d: 'Abhängigkeit von zwei Gründern in der Frühphase. Wissensverlust bei Ausfall.', m: 'Doku aller Prozesse in MkDocs. KI-Codebasis mit Tests. ESOP-Pool ab Hire 1. Frühe Einstellung Rechtsanwalt/DS.' },
|
||||
{ t: 'Langsame Kundenakquise', sev: 'Mittel', sevColor: '#d97706', timeline: 'Jahr 1-3', d: 'B2B-Verkaufszyklen 3-9 Monate. Compliance-Budgets jährlich geplant.', m: 'Beratungsumsätze 5-30k/Mon überbrücken Anlauf. Channel (Bechtle/CANCOM) skaliert schneller. Land-and-Expand.' },
|
||||
{ t: 'Regulatorische Änderungen', sev: 'Niedrig', sevColor: '#16a34a', timeline: 'Laufend', d: 'Neue EU-Gesetze erfordern Anpassung der Plattform.', m: 'Jede Änderung vergrößert unseren Markt. RAG-Pipeline indexiert neue Regularien in Tagen. 380+ schon im System.' },
|
||||
{ t: 'Liquiditätsrisiko', sev: 'Niedrig', sevColor: '#16a34a', timeline: 'Jahr 1-2', d: 'Mit 200k Wandeldarlehen ist die Runway begrenzt. Ende 2027 nahe Null.', m: 'Organisches Wachstum durch Beratung. Break-Even 2029. Pre-Seed BW (L-Bank) verdoppelt Finanzierung auf 400k.' },
|
||||
]
|
||||
const RISKS_EN = [
|
||||
{ t: 'AI Commoditization', sev: 'High', sevColor: '#dc2626', timeline: '3-5 yrs', d: 'LLMs lower entry barriers — control generation, DPIA creation, policy templates become commodity.', m: 'We compete on Layers 2-6: integration, auditability, workflows, EU hosting. AI is multiplier, not product.' },
|
||||
{ t: 'US Platform Expansion', sev: 'Medium', sevColor: '#d97706', timeline: '2-4 yrs', d: 'Microsoft Purview, Vanta or Drata expand with localized EU offering.', m: 'Structural advantage: 100% EU infra, no US SaaS, works council compliance. CLOUD Act is a deal-breaker.' },
|
||||
{ t: 'Team Risk / Key Person', sev: 'Medium', sevColor: '#d97706', timeline: 'Year 1-2', d: 'Dependency on two founders. Knowledge loss in case of absence.', m: 'All processes documented in MkDocs. AI-assisted codebase with tests. ESOP pool from hire 1. Early lawyer hire.' },
|
||||
{ t: 'Slow Customer Acquisition', sev: 'Medium', sevColor: '#d97706', timeline: 'Year 1-3', d: 'B2B sales cycles 3-9 months. Compliance budgets planned annually.', m: 'Consulting revenue 5-30k/month bridges ramp. Channel (Bechtle/CANCOM) scales faster. Land-and-expand.' },
|
||||
{ t: 'Regulatory Changes', sev: 'Low', sevColor: '#16a34a', timeline: 'Ongoing', d: 'New EU laws require platform adaptation.', m: 'Every change enlarges our market. RAG pipeline indexes new regulations in days. 380+ already in system.' },
|
||||
{ t: 'Liquidity Risk', sev: 'Low', sevColor: '#16a34a', timeline: 'Year 1-2', d: 'With 200k convertible loan, runway is limited. Near zero by end of 2027.', m: 'Organic growth via consulting. Break-even 2029. Pre-Seed BW (L-Bank) doubles funding to 400k.' },
|
||||
]
|
||||
/* ===== RISKS ===== */
|
||||
|
||||
export function PrintRisksPage({ lang, pageNum, totalPages, versionName }: SlideBase) {
|
||||
const de = lang === 'de'
|
||||
const risks = de ? RISKS_DE : RISKS_EN
|
||||
const rows = de ? [
|
||||
['Markt', 'Adoption langsamer als geplant', 'Mittel', 'Mittel', 'White-Glove-Onboarding, Channel über IHK/VDMA, Free-Tier für KMU <10 MA'],
|
||||
['Markt', 'Vanta/Drata gehen EU-First', 'Niedrig', 'Hoch', 'BreakPilot-Vorsprung 18 Monate, Code-Security ist Moat, EU-Marken-DPMA + EUIPO gesichert'],
|
||||
['Technologie', 'LLM-Anbieter (Qwen, DeepSeek) ändern Lizenz', 'Niedrig', 'Mittel', 'Multi-LLM-Strategie via LiteLLM, Offline-Modelle, eigene Embeddings'],
|
||||
['Technologie', 'False-Positive in Code-Scans', 'Hoch', 'Niedrig', 'AI-Triage senkt False-Positives auf <2%, manueller Review-Loop, kontinuierliches Training'],
|
||||
['Regulatorik', 'AI Act zwingt zu Konformitätsbewertung', 'Hoch', 'Mittel', 'Eigene Konformitätsbewertung im 2. Half 2026, sind selbst Hochrisiko-KI'],
|
||||
['Regulatorik', 'Schrems III ändert EU-Datenfluss', 'Mittel', 'Niedrig', '100% EU-Hosting bereits umgesetzt, kein Datentransfer in USA'],
|
||||
['Team', 'CTO-Abgang', 'Niedrig', 'Sehr hoch', 'CTO ist Gründer mit 37,3% Equity + Vesting, 4-Jahres-Bindung, Code-Doku exzellent'],
|
||||
['Team', 'Senior-Engineers nicht skalierbar', 'Mittel', 'Mittel', 'Remote-First, Region Konstanz/Bodensee, Anthropic-/Google-Netzwerk des CTO'],
|
||||
['Finanzen', 'Funding-Verzögerung', 'Mittel', 'Mittel', 'Gründerzuschuss aktiv, INVEST-Zuschuss vorbereitet, Wandeldarlehen-Bridge möglich'],
|
||||
['Operativ', 'Compliance-Verstoß im eigenen Betrieb', 'Niedrig', 'Hoch', 'BreakPilot ist eigener Kunde, audit-ready by design, DSB extern bestellt'],
|
||||
] : [
|
||||
['Market', 'Adoption slower than planned', 'Medium', 'Medium', 'White-glove onboarding, channel via IHK/VDMA, free tier for SMEs <10 emp.'],
|
||||
['Market', 'Vanta/Drata go EU-first', 'Low', 'High', 'BreakPilot lead 18 months, code security is moat, EU trademark DPMA + EUIPO secured'],
|
||||
['Technology', 'LLM providers (Qwen, DeepSeek) change license', 'Low', 'Medium', 'Multi-LLM strategy via LiteLLM, offline models, own embeddings'],
|
||||
['Technology', 'False positives in code scans', 'High', 'Low', 'AI triage lowers false positives to <2%, manual review loop, continuous training'],
|
||||
['Regulatory', 'AI Act mandates conformity assessment', 'High', 'Medium', 'Own conformity assessment in 2nd half 2026, we are high-risk AI ourselves'],
|
||||
['Regulatory', 'Schrems III changes EU data flow', 'Medium', 'Low', '100% EU hosting already in place, no data transfer to USA'],
|
||||
['Team', 'CTO departure', 'Low', 'Very high', 'CTO is founder with 37.3% equity + vesting, 4-year tie-in, code documentation excellent'],
|
||||
['Team', 'Senior engineers not scalable', 'Medium', 'Medium', "Remote-first, Konstanz/Bodensee region, CTO's Anthropic/Google network"],
|
||||
['Finance', 'Funding delay', 'Medium', 'Medium', 'Founder grant active, INVEST grant prepared, convertible loan bridge possible'],
|
||||
['Operations', 'Compliance violation in own operations', 'Low', 'High', 'BreakPilot is its own customer, audit-ready by design, external DPO appointed'],
|
||||
]
|
||||
|
||||
return (
|
||||
<PrintPage title={de ? 'Risiken & Mitigation' : 'Risks & Mitigation'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
|
||||
<SectionTitle subtitle={de ? 'Transparente Darstellung der wesentlichen Risiken und unserer Gegenmaßnahmen' : 'Transparent presentation of key risks and our countermeasures'}>
|
||||
{de ? 'Risiken & Mitigation' : 'Risks & Mitigation'}
|
||||
</SectionTitle>
|
||||
<PrintTable
|
||||
headers={[de ? 'Risiko' : 'Risk', de ? 'Schwere' : 'Severity', de ? 'Zeit' : 'Time', de ? 'Beschreibung' : 'Description', 'Mitigation']}
|
||||
rows={risks.map(r => [
|
||||
<strong key="t" style={{ color: COLORS.dark }}>{r.t}</strong>,
|
||||
<span key="s" style={{ color: r.sevColor, fontWeight: 700 }}>{r.sev}</span>,
|
||||
r.timeline,
|
||||
r.d,
|
||||
<span key="m" style={{ color: COLORS.med }}>{r.m}</span>,
|
||||
])}
|
||||
colWidths={['17%', '8%', '10%', '32%', '33%']}
|
||||
<Page kicker="23" section={de ? 'RISIKEN & MITIGATION' : 'RISKS & MITIGATION'} title={de ? 'Bekannte Risiken, mit Mitigationen, die wir bereits umgesetzt haben.' : 'Known risks, with mitigations we have already implemented.'} subtitle={de ? 'Zehn Risiken in fünf Kategorien. Eintritts-Wahrscheinlichkeit × Auswirkung. Keine Show-Stopper.' : 'Ten risks across five categories. Probability × impact. No show-stoppers.'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
|
||||
|
||||
<DataTable
|
||||
cols={[
|
||||
{ header: de ? 'Kategorie' : 'Category', width: '11%' },
|
||||
{ header: de ? 'Risiko' : 'Risk', width: '28%' },
|
||||
{ header: de ? 'Wahrsch.' : 'Prob.', width: '10%' },
|
||||
{ header: de ? 'Auswirkung' : 'Impact', width: '10%' },
|
||||
{ header: de ? 'Mitigation' : 'Mitigation' },
|
||||
]}
|
||||
rows={rows}
|
||||
dense
|
||||
highlightFirstCol
|
||||
/>
|
||||
<div style={{ marginTop: '10px', padding: '10px 14px', background: '#f5f3ff', borderRadius: '6px', borderLeft: `3px solid ${COLORS.indigo}`, WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
<p style={{ fontSize: '10px', color: COLORS.med, margin: 0, fontStyle: 'italic', lineHeight: 1.5 }}>
|
||||
|
||||
<div style={{ marginTop: '5mm', flexShrink: 0 }}>
|
||||
<Callout tone="positive" label={de ? 'Wir leben unsere eigene Medizin' : 'We eat our own dogfood'}>
|
||||
{de
|
||||
? '„Wir konkurrieren nicht mit KI. Wir konkurrieren mit Teams, die KI besser einsetzen als wir. Deshalb bauen wir nicht das beste LLM, sondern die vertrauenswürdigste Compliance-Infrastruktur."'
|
||||
: '"We don\'t compete with AI. We compete with teams that use AI better than we do. That is why we don\'t build the best LLM but the most trustworthy compliance infrastructure."'}
|
||||
</p>
|
||||
? 'BreakPilot scannt seinen eigenen Code. Wir generieren unsere eigene VVT, eigene DSFA, eigene TOMs. Wir sind audit-ready bevor wir den ersten Kunden onboarden, und das ist die beste Mitigation für alle Compliance-bezogenen Risiken.'
|
||||
: 'BreakPilot scans its own code. We generate our own RoPA, own DPIA, own TOMs. We are audit-ready before onboarding our first customer, and that is the best mitigation for all compliance-related risks.'}
|
||||
</Callout>
|
||||
</div>
|
||||
</PrintPage>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
const GLOSSARY = {
|
||||
de: [
|
||||
{ cat: 'Code Security & DevSecOps', color: '#ef4444', terms: [['SAST', 'Static Application Security Testing — Quellcode-Analyse'], ['DAST', 'Dynamic Application Security Testing — Laufzeit-Tests'], ['SBOM', 'Software Bill of Materials — Komponenten-Liste'], ['SCA', 'Software Composition Analysis'], ['DevSecOps', 'Sicherheit integriert in Entwicklung'], ['CI/CD', 'Automatisierte Build/Deploy-Pipeline']] },
|
||||
{ cat: 'Compliance & Datenschutz', color: COLORS.indigo, terms: [['DSGVO', 'EU-Datenschutzverordnung seit Mai 2018'], ['VVT', 'Verzeichnis von Verarbeitungstätigkeiten'], ['TOMs', 'Technisch-Organisatorische Maßnahmen'], ['DSFA', 'Datenschutz-Folgenabschätzung'], ['DSR', 'Betroffenenrechte (Auskunft, Löschung)'], ['DSB', 'Datenschutzbeauftragter'], ['ISMS', 'Information Security Management System']] },
|
||||
{ cat: 'EU-Regulierungen', color: '#06b6d4', terms: [['AI Act', 'KI-Verordnung EU 2024/1689 — Risikoklassen'], ['CRA', 'Cyber Resilience Act — SBOM-Pflicht'], ['NIS2', 'EU-Cybersicherheits-Richtlinie'], ['MVO', 'Maschinenverordnung 2023/1230'], ['Cloud Act', 'US-Gesetz für extraterritorialen Datenzugriff'], ['FISA 702', 'US-Überwachungsgesetz'], ['BDSG', 'Bundesdatenschutzgesetz'], ['TISAX', 'Automotive Security Standard'], ['BSI', 'Bundesamt für Sicherheit in der IT']] },
|
||||
{ cat: 'Kennzahlen', color: '#10b981', terms: [['ARR', 'Annual Recurring Revenue'], ['MRR', 'Monthly Recurring Revenue'], ['CAC', 'Customer Acquisition Cost'], ['LTV', 'Lifetime Value'], ['ARPU', 'Avg. Revenue Per User'], ['SaaS', 'Software as a Service'], ['ESOP', 'Employee Stock Option Plan'], ['ROI', 'Return on Investment']] },
|
||||
{ cat: 'Technologie', color: '#f59e0b', terms: [['RAG', 'Retrieval Augmented Generation'], ['LLM', 'Large Language Model'], ['UCCA', 'Use-Case Compliance Assessment'], ['FRIA', 'Fundamental Rights Impact Assessment'], ['SDK', 'Software Development Kit'], ['OWASP', 'Open Web Application Security Project'], ['NIST', 'US-Standardisierungsbehörde'], ['ENISA', 'EU-Agentur für Cybersicherheit'], ['CE', 'EU-Konformitätskennzeichnung'], ['RFQ', 'Request for Quotation']] },
|
||||
],
|
||||
en: [
|
||||
{ cat: 'Code Security & DevSecOps', color: '#ef4444', terms: [['SAST', 'Static Application Security Testing — source code'], ['DAST', 'Dynamic Application Security Testing — runtime'], ['SBOM', 'Software Bill of Materials — component list'], ['SCA', 'Software Composition Analysis'], ['DevSecOps', 'Security integrated into development'], ['CI/CD', 'Automated build/deploy pipeline']] },
|
||||
{ cat: 'Compliance & Data Protection', color: COLORS.indigo, terms: [['GDPR', 'EU data protection regulation since May 2018'], ['RoPA', 'Record of Processing Activities'], ['TOMs', 'Technical & Organizational Measures'], ['DPIA', 'Data Protection Impact Assessment'], ['DSR', 'Data subject rights (access, erasure)'], ['DPO', 'Data Protection Officer'], ['ISMS', 'Information Security Management System']] },
|
||||
{ cat: 'EU Regulations', color: '#06b6d4', terms: [['AI Act', 'AI Regulation EU 2024/1689 — risk classes'], ['CRA', 'Cyber Resilience Act — SBOM mandatory'], ['NIS2', 'EU cybersecurity directive'], ['MVO', 'Machinery Regulation 2023/1230'], ['Cloud Act', 'US law for extraterritorial data access'], ['FISA 702', 'US surveillance law'], ['BDSG', 'German Federal Data Protection Act'], ['TISAX', 'Automotive security standard'], ['BSI', 'German Federal Office for IT Security']] },
|
||||
{ cat: 'Business Metrics', color: '#10b981', terms: [['ARR', 'Annual Recurring Revenue'], ['MRR', 'Monthly Recurring Revenue'], ['CAC', 'Customer Acquisition Cost'], ['LTV', 'Lifetime Value'], ['ARPU', 'Avg. Revenue Per User'], ['SaaS', 'Software as a Service'], ['ESOP', 'Employee Stock Option Plan'], ['ROI', 'Return on Investment']] },
|
||||
{ cat: 'Technology', color: '#f59e0b', terms: [['RAG', 'Retrieval Augmented Generation'], ['LLM', 'Large Language Model'], ['UCCA', 'Use-Case Compliance Assessment'], ['FRIA', 'Fundamental Rights Impact Assessment'], ['SDK', 'Software Development Kit'], ['OWASP', 'Open Web Application Security Project'], ['NIST', 'US standards body'], ['ENISA', 'EU Agency for Cybersecurity'], ['CE', 'EU conformity marking'], ['RFQ', 'Request for Quotation']] },
|
||||
],
|
||||
}
|
||||
/* ===== GLOSSARY ===== */
|
||||
|
||||
const GLOSSARY: [string, string][] = [
|
||||
['AI Act', 'EU-Verordnung 2024/1689. Klassifiziert KI-Systeme in 4 Risikoklassen. Hochrisiko-KI benötigt DSFA + Konformitätsbewertung.'],
|
||||
['Audit-Trail', 'Lückenlose Nachverfolgung jeder Compliance-relevanten Änderung, wer, was, wann, warum. Auditor-tauglich.'],
|
||||
['BSI-C5', 'Cloud Computing Compliance Criteria Catalogue des BSI. Standard für Cloud-Sicherheit in DE.'],
|
||||
['BSI-KritisV', 'KRITIS-Verordnung. Definiert kritische Infrastrukturen, die NIS2-Maßnahmen umsetzen müssen.'],
|
||||
['CE-Kennzeichnung', 'Conformité Européenne. Pflicht für Produkte im EU-Binnenmarkt. Software-Anteil wird seit MaschVO 2023 bewertet.'],
|
||||
['CRA', 'Cyber Resilience Act (VO 2024/2847). Verbindlich ab Dez 2027 für Produkte mit digitalen Elementen.'],
|
||||
['DAST', 'Dynamic Application Security Testing. Prüft laufende Apps gegen Angriffe.'],
|
||||
['DORA', 'Digital Operational Resilience Act (VO 2022/2554). Für Finanzsektor seit Jan 2025.'],
|
||||
['DSFA', 'Datenschutz-Folgenabschätzung (Art. 35 DSGVO). Pflicht bei hohem Risiko für Betroffene.'],
|
||||
['DSR', 'Data Subject Request. Betroffenenrechte (Auskunft, Berichtigung, Löschung, Portabilität).'],
|
||||
['EUIPO', 'European Union Intellectual Property Office. EU-weite Markenanmeldung.'],
|
||||
['FISA 702', 'US-Gesetz zur Überwachung ausländischer Daten, gilt extraterritorial für US-Unternehmen.'],
|
||||
['INVEST-Zuschuss', 'BMWi-Förderung: 20% des Investments rückzahlungsfrei (bis €100k pro Investor / Jahr).'],
|
||||
['LiteLLM', 'Open-Source-Proxy für OpenAI-kompatible APIs. Failover, Rate-Limiting, PII-Filter.'],
|
||||
['Lines of Code (LoC)', 'Quelltextzeilen ohne Leerzeilen + Kommentare. Indikator für Engineering-Volumen.'],
|
||||
['MaschVO', 'EU-Maschinenverordnung 2023/1230. Verbindlich ab Jan 2027.'],
|
||||
['MCP', 'Model Context Protocol. Anthropic-Standard für Tool-Integration in LLMs.'],
|
||||
['MDR', 'Medical Device Regulation (VO 2017/745). Für Medizinprodukte inkl. Software.'],
|
||||
['NIS2', 'Network and Information Security Directive 2 (RL 2022/2555). Verbindlich seit Okt 2024 für ~30k DE-Unternehmen.'],
|
||||
['OPA', 'Open Policy Agent. Policy-as-Code für Autorisierung und Compliance-Regeln.'],
|
||||
['RAG', 'Retrieval-Augmented Generation. LLM mit Zugriff auf externe Wissensbasis.'],
|
||||
['RFQ', 'Request for Quotation. Anfrage zur Angebotsabgabe.'],
|
||||
['SAST', 'Static Application Security Testing. Code-Analyse ohne Ausführung.'],
|
||||
['SBOM', 'Software Bill of Materials. Inventar aller Komponenten + Lizenzen. CRA-Pflicht ab 2027.'],
|
||||
['Schrems II', 'EuGH-Urteil C-311/18 (2020). Erklärte EU-US Privacy Shield für ungültig.'],
|
||||
['Self-Hosted', 'Software, die der Kunde auf eigener Infrastruktur betreibt, kein externer SaaS.'],
|
||||
['TISAX', 'Trusted Information Security Assessment Exchange. Automotive-Sicherheitsstandard.'],
|
||||
['TOM', 'Technische und organisatorische Maßnahmen (Art. 32 DSGVO).'],
|
||||
['VVT', 'Verzeichnis von Verarbeitungstätigkeiten (Art. 30 DSGVO). Pflicht ab 250 Mitarbeitern.'],
|
||||
['Wandeldarlehen', 'Convertible Loan. Hybrid-Instrument zwischen Kredit und Equity. SAFE-ähnlich.'],
|
||||
]
|
||||
|
||||
export function PrintGlossaryPage({ lang, pageNum, totalPages, versionName }: SlideBase) {
|
||||
const de = lang === 'de'
|
||||
const cats = de ? GLOSSARY.de : GLOSSARY.en
|
||||
const half = Math.ceil(GLOSSARY.length / 2)
|
||||
const left = GLOSSARY.slice(0, half)
|
||||
const right = GLOSSARY.slice(half)
|
||||
|
||||
return (
|
||||
<PrintPage title={de ? 'Glossar' : 'Glossary'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
|
||||
<SectionTitle subtitle={de ? 'Schlüsselbegriffe aus Security, Compliance, Regulatorik und SaaS-Metriken' : 'Key terms across security, compliance, regulation and SaaS metrics'}>
|
||||
{de ? 'Anhang · Glossar & Abkürzungen' : 'Appendix · Glossary & Abbreviations'}
|
||||
</SectionTitle>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '8px', flex: 1 }}>
|
||||
{cats.map(c => (
|
||||
<div key={c.cat} style={{ border: `1px solid ${COLORS.border}`, borderRadius: '6px', padding: '8px 10px' }}>
|
||||
<p style={{ fontSize: '9px', fontWeight: 700, color: c.color, textTransform: 'uppercase', letterSpacing: '0.04em', margin: '0 0 4px' }}>{c.cat}</p>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<tbody>
|
||||
{c.terms.map(([abbr, desc]) => (
|
||||
<tr key={abbr}>
|
||||
<td style={{ fontSize: '8px', fontWeight: 700, color: c.color, padding: '2px 6px 2px 0', verticalAlign: 'top', whiteSpace: 'nowrap', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>{abbr}</td>
|
||||
<td style={{ fontSize: '8px', color: COLORS.med, padding: '2px 0', lineHeight: 1.4 }}>{desc}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<Page kicker="24" section={de ? 'ANHANG · GLOSSAR' : 'APPENDIX · GLOSSARY'} title={de ? 'Begriffsdefinitionen, alle Akronyme und Fachterme in diesem Dokument.' : 'Term definitions, all acronyms and technical terms in this document.'} subtitle={de ? '30 Begriffe, Compliance, Regulatorik, Engineering, Finanzierung.' : '30 terms, compliance, regulation, engineering, financing.'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8mm', fontSize: '8pt' }}>
|
||||
{[left, right].map((col, ci) => (
|
||||
<div key={ci}>
|
||||
{col.map((g, i) => (
|
||||
<div key={i} style={{ display: 'grid', gridTemplateColumns: '22mm 1fr', gap: '3mm', padding: '1.5mm 0', borderBottom: `1px solid ${COLORS.slate100}` }}>
|
||||
<div style={{ fontWeight: 700, color: COLORS.indigo600, fontSize: '8pt' }}>{g[0]}</div>
|
||||
<div style={{ color: COLORS.slate700, lineHeight: 1.4 }}>{g[1]}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</PrintPage>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
import React from 'react'
|
||||
import { COLORS } from './PrintLayout'
|
||||
|
||||
/* ====================================================================== */
|
||||
/* CHARTS */
|
||||
/* ====================================================================== */
|
||||
|
||||
interface BarSeries {
|
||||
label: string
|
||||
value: number
|
||||
/** Optional secondary label rendered above the bar value (e.g. "Mio."). */
|
||||
unit?: string
|
||||
tone?: 'default' | 'positive' | 'negative' | 'accent'
|
||||
}
|
||||
|
||||
export function BarChart({
|
||||
data, height = 36, maxOverride, formatValue, title, yAxisHint,
|
||||
}: {
|
||||
data: BarSeries[]
|
||||
height?: number // mm
|
||||
maxOverride?: number
|
||||
formatValue?: (n: number) => string
|
||||
title?: string
|
||||
yAxisHint?: string
|
||||
}) {
|
||||
const max = maxOverride ?? Math.max(...data.map(d => d.value), 1)
|
||||
const fmt = formatValue ?? ((n: number) => n.toLocaleString('de-DE'))
|
||||
const ticks = [0, 0.25, 0.5, 0.75, 1].map(t => Math.round(max * t))
|
||||
|
||||
return (
|
||||
<div>
|
||||
{(title || yAxisHint) && (
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', marginBottom: '2mm' }}>
|
||||
{title && <div style={{ fontSize: '8pt', fontWeight: 700, color: COLORS.slate600, textTransform: 'uppercase', letterSpacing: '0.1em' }}>{title}</div>}
|
||||
{yAxisHint && <div style={{ fontSize: '7pt', color: COLORS.slate400 }}>{yAxisHint}</div>}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', alignItems: 'stretch', gap: '4mm', position: 'relative' }}>
|
||||
{/* Y-axis ticks */}
|
||||
<div style={{ width: '14mm', display: 'flex', flexDirection: 'column', justifyContent: 'space-between', height: `${height}mm`, fontSize: '6.5pt', color: COLORS.slate400, textAlign: 'right', fontVariantNumeric: 'tabular-nums' }}>
|
||||
{ticks.slice().reverse().map((t, i) => (
|
||||
<div key={i}>{fmt(t)}</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Bars + grid */}
|
||||
<div style={{ flex: 1, position: 'relative', height: `${height}mm`, borderLeft: `1px solid ${COLORS.slate300}`, borderBottom: `1px solid ${COLORS.slate300}` }}>
|
||||
{/* Grid lines */}
|
||||
{ticks.slice(1).map((_, i) => (
|
||||
<div key={i} style={{ position: 'absolute', left: 0, right: 0, top: `${(1 - (i + 1) / 4) * 100}%`, height: '0.5pt', background: COLORS.slate100, WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }} />
|
||||
))}
|
||||
{/* Bars */}
|
||||
<div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'flex-end', gap: '3mm', padding: '0 2mm' }}>
|
||||
{data.map((d, i) => {
|
||||
const h = (d.value / max) * 100
|
||||
const color = d.tone === 'positive' ? COLORS.emerald600
|
||||
: d.tone === 'negative' ? COLORS.red600
|
||||
: d.tone === 'accent' ? COLORS.amber600
|
||||
: COLORS.indigo600
|
||||
return (
|
||||
<div key={i} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'flex-end', height: '100%', position: 'relative' }}>
|
||||
<div style={{ fontSize: '7pt', fontWeight: 700, color: color, marginBottom: '0.8mm', fontVariantNumeric: 'tabular-nums', whiteSpace: 'nowrap' }}>{fmt(d.value)}</div>
|
||||
<div style={{ width: '100%', height: `${h}%`, minHeight: '1pt', background: color, WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* X-axis labels */}
|
||||
<div style={{ display: 'flex', gap: '3mm', paddingLeft: '18mm', paddingRight: '2mm', marginTop: '1mm' }}>
|
||||
{data.map((d, i) => (
|
||||
<div key={i} style={{ flex: 1, fontSize: '7pt', color: COLORS.slate500, textAlign: 'center', fontWeight: 600, fontVariantNumeric: 'tabular-nums' }}>{d.label}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface LinePoint { label: string; value: number }
|
||||
|
||||
export function LineChart({
|
||||
data, height = 36, formatValue, color = COLORS.indigo600, title, fill = true,
|
||||
}: {
|
||||
data: LinePoint[]
|
||||
height?: number
|
||||
formatValue?: (n: number) => string
|
||||
color?: string
|
||||
title?: string
|
||||
fill?: boolean
|
||||
}) {
|
||||
if (data.length < 2) return null
|
||||
const max = Math.max(...data.map(d => d.value), 1)
|
||||
const fmt = formatValue ?? ((n: number) => n.toLocaleString('de-DE'))
|
||||
const w = 100
|
||||
const points = data.map((d, i) => ({
|
||||
x: (i / (data.length - 1)) * w,
|
||||
y: 100 - (d.value / max) * 100,
|
||||
v: d.value,
|
||||
label: d.label,
|
||||
}))
|
||||
const pathD = points.map((p, i) => (i === 0 ? `M${p.x},${p.y}` : `L${p.x},${p.y}`)).join(' ')
|
||||
const areaD = `${pathD} L100,100 L0,100 Z`
|
||||
|
||||
return (
|
||||
<div>
|
||||
{title && <div style={{ fontSize: '8pt', fontWeight: 700, color: COLORS.slate600, textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '2mm' }}>{title}</div>}
|
||||
<div style={{ position: 'relative', height: `${height}mm`, borderLeft: `1px solid ${COLORS.slate300}`, borderBottom: `1px solid ${COLORS.slate300}` }}>
|
||||
<svg viewBox="0 0 100 100" preserveAspectRatio="none" style={{ position: 'absolute', inset: 0, width: '100%', height: '100%' }}>
|
||||
{/* Grid */}
|
||||
{[0.25, 0.5, 0.75].map(t => (
|
||||
<line key={t} x1="0" x2="100" y1={t * 100} y2={t * 100} stroke={COLORS.slate100} strokeWidth="0.3" />
|
||||
))}
|
||||
{fill && <path d={areaD} fill={color} fillOpacity="0.12" />}
|
||||
<path d={pathD} fill="none" stroke={color} strokeWidth="0.6" strokeLinejoin="round" strokeLinecap="round" />
|
||||
{points.map((p, i) => (
|
||||
<circle key={i} cx={p.x} cy={p.y} r="1.2" fill={color} />
|
||||
))}
|
||||
</svg>
|
||||
{/* Value labels above each point */}
|
||||
<div style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }}>
|
||||
{points.map((p, i) => (
|
||||
<div key={i} style={{ position: 'absolute', left: `${p.x}%`, top: `${p.y}%`, transform: 'translate(-50%, -120%)', fontSize: '6.5pt', fontWeight: 700, color, whiteSpace: 'nowrap', fontVariantNumeric: 'tabular-nums' }}>{fmt(p.v)}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* X labels */}
|
||||
<div style={{ display: 'flex', marginTop: '1mm' }}>
|
||||
{points.map((p, i) => (
|
||||
<div key={i} style={{ flex: 1, fontSize: '7pt', color: COLORS.slate500, textAlign: 'center', fontWeight: 600, fontVariantNumeric: 'tabular-nums' }}>{p.label}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Horizontal stacked-bar comparison (e.g. "you pay" vs "you save") */
|
||||
export function ComparisonBars({
|
||||
rows, formatValue,
|
||||
}: {
|
||||
rows: { label: string; bars: { tone: 'positive' | 'negative' | 'accent' | 'default'; value: number; cap?: string }[] }[]
|
||||
formatValue?: (n: number) => string
|
||||
}) {
|
||||
const max = Math.max(...rows.flatMap(r => r.bars.map(b => b.value)), 1)
|
||||
const fmt = formatValue ?? ((n: number) => n.toLocaleString('de-DE'))
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '3mm' }}>
|
||||
{rows.map((row, i) => (
|
||||
<div key={i}>
|
||||
<div style={{ fontSize: '8pt', fontWeight: 700, color: COLORS.slate700, marginBottom: '1.5mm' }}>{row.label}</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1mm' }}>
|
||||
{row.bars.map((b, j) => {
|
||||
const w = (b.value / max) * 100
|
||||
const color = b.tone === 'positive' ? COLORS.emerald600
|
||||
: b.tone === 'negative' ? COLORS.red600
|
||||
: b.tone === 'accent' ? COLORS.amber600
|
||||
: COLORS.indigo600
|
||||
return (
|
||||
<div key={j} style={{ display: 'flex', alignItems: 'center', gap: '3mm' }}>
|
||||
<div style={{ flex: 1, position: 'relative', height: '4mm', background: COLORS.slate50, WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
<div style={{ position: 'absolute', left: 0, top: 0, bottom: 0, width: `${w}%`, background: color, WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }} />
|
||||
{b.cap && <div style={{ position: 'absolute', left: '2mm', top: 0, bottom: 0, display: 'flex', alignItems: 'center', fontSize: '7pt', color: '#ffffff', fontWeight: 700, letterSpacing: '0.04em' }}>{b.cap}</div>}
|
||||
</div>
|
||||
<div style={{ width: '20mm', textAlign: 'right', fontSize: '9pt', fontWeight: 800, color, fontVariantNumeric: 'tabular-nums' }}>{fmt(b.value)}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Donut chart for percentages (use-of-funds, equity, etc.) */
|
||||
export function DonutChart({
|
||||
segments, size = 32, thickness = 6,
|
||||
}: {
|
||||
segments: { label: string; pct: number; color: string }[]
|
||||
size?: number // mm
|
||||
thickness?: number // mm
|
||||
}) {
|
||||
const R = 50
|
||||
const r = R - (thickness / size) * 50
|
||||
let acc = 0
|
||||
const arcs = segments.map(s => {
|
||||
const start = acc / 100 * Math.PI * 2 - Math.PI / 2
|
||||
acc += s.pct
|
||||
const end = acc / 100 * Math.PI * 2 - Math.PI / 2
|
||||
const x1 = 50 + R * Math.cos(start), y1 = 50 + R * Math.sin(start)
|
||||
const x2 = 50 + R * Math.cos(end), y2 = 50 + R * Math.sin(end)
|
||||
const x3 = 50 + r * Math.cos(end), y3 = 50 + r * Math.sin(end)
|
||||
const x4 = 50 + r * Math.cos(start), y4 = 50 + r * Math.sin(start)
|
||||
const large = s.pct > 50 ? 1 : 0
|
||||
const d = `M${x1},${y1} A${R},${R} 0 ${large} 1 ${x2},${y2} L${x3},${y3} A${r},${r} 0 ${large} 0 ${x4},${y4} Z`
|
||||
return { d, color: s.color, pct: s.pct, label: s.label }
|
||||
})
|
||||
return (
|
||||
<svg viewBox="0 0 100 100" style={{ width: `${size}mm`, height: `${size}mm`, display: 'block' }}>
|
||||
{arcs.map((a, i) => (
|
||||
<path key={i} d={a.d} fill={a.color} />
|
||||
))}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** Progress meter (0-100%) — horizontal */
|
||||
export function ProgressBar({ pct, color = COLORS.indigo600, label, value }: { pct: number; color?: string; label?: string; value?: string }) {
|
||||
return (
|
||||
<div>
|
||||
{(label || value) && (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: '1mm', fontSize: '8pt' }}>
|
||||
{label && <span style={{ color: COLORS.slate700, fontWeight: 500 }}>{label}</span>}
|
||||
{value && <span style={{ color: COLORS.slate900, fontWeight: 700, fontVariantNumeric: 'tabular-nums' }}>{value}</span>}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ height: '3mm', background: COLORS.slate100, position: 'relative', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
<div style={{ position: 'absolute', left: 0, top: 0, bottom: 0, width: `${Math.min(100, Math.max(0, pct))}%`, background: color, WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Nested market-size visual (TAM/SAM/SOM) */
|
||||
export function MarketFunnel({
|
||||
tam, sam, som, fmt,
|
||||
}: {
|
||||
tam: { value: number; label: string; growth?: number; note?: string }
|
||||
sam: { value: number; label: string; growth?: number; note?: string }
|
||||
som: { value: number; label: string; growth?: number; note?: string }
|
||||
fmt: (v: number) => string
|
||||
}) {
|
||||
const samPct = sam.value / tam.value
|
||||
const somPct = som.value / tam.value
|
||||
return (
|
||||
<div>
|
||||
{/* TAM outer */}
|
||||
<div style={{ border: `1px solid ${COLORS.slate300}`, padding: '5mm', position: 'relative' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', marginBottom: '1.5mm' }}>
|
||||
<span style={{ fontSize: '8pt', fontWeight: 700, color: COLORS.slate500, textTransform: 'uppercase', letterSpacing: '0.14em' }}>TAM · {tam.label}</span>
|
||||
{tam.growth != null && <span style={{ fontSize: '7.5pt', color: COLORS.slate500, fontWeight: 600 }}>+{tam.growth}% p.a.</span>}
|
||||
</div>
|
||||
<div style={{ fontSize: '32pt', fontWeight: 800, color: COLORS.slate900, lineHeight: 1, letterSpacing: '-0.025em', fontVariantNumeric: 'tabular-nums' }}>{fmt(tam.value)}</div>
|
||||
{tam.note && <div style={{ fontSize: '8pt', color: COLORS.slate600, marginTop: '2mm', maxWidth: '120mm' }}>{tam.note}</div>}
|
||||
|
||||
{/* SAM inner */}
|
||||
<div style={{ marginTop: '4mm', marginLeft: '10mm', border: `1px solid ${COLORS.indigo600}`, background: COLORS.indigo50, padding: '4mm', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', marginBottom: '1.5mm' }}>
|
||||
<span style={{ fontSize: '8pt', fontWeight: 700, color: COLORS.indigo700, textTransform: 'uppercase', letterSpacing: '0.14em' }}>SAM · {sam.label}</span>
|
||||
{sam.growth != null && <span style={{ fontSize: '7.5pt', color: COLORS.indigo700, fontWeight: 600 }}>+{sam.growth}% p.a. · {Math.round(samPct * 100)}% TAM</span>}
|
||||
</div>
|
||||
<div style={{ fontSize: '26pt', fontWeight: 800, color: COLORS.indigo700, lineHeight: 1, letterSpacing: '-0.02em', fontVariantNumeric: 'tabular-nums' }}>{fmt(sam.value)}</div>
|
||||
{sam.note && <div style={{ fontSize: '8pt', color: COLORS.slate700, marginTop: '2mm' }}>{sam.note}</div>}
|
||||
|
||||
{/* SOM inner-inner */}
|
||||
<div style={{ marginTop: '3mm', marginLeft: '8mm', border: `2px solid ${COLORS.emerald600}`, background: COLORS.emerald50, padding: '4mm', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', marginBottom: '1.5mm' }}>
|
||||
<span style={{ fontSize: '8pt', fontWeight: 700, color: COLORS.emerald700, textTransform: 'uppercase', letterSpacing: '0.14em' }}>SOM · {som.label}</span>
|
||||
{som.growth != null && <span style={{ fontSize: '7.5pt', color: COLORS.emerald700, fontWeight: 600 }}>+{som.growth}% p.a. · {(somPct * 100).toFixed(1)}% TAM</span>}
|
||||
</div>
|
||||
<div style={{ fontSize: '24pt', fontWeight: 800, color: COLORS.emerald700, lineHeight: 1, letterSpacing: '-0.02em', fontVariantNumeric: 'tabular-nums' }}>{fmt(som.value)}</div>
|
||||
{som.note && <div style={{ fontSize: '8pt', color: COLORS.slate700, marginTop: '2mm' }}>{som.note}</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { Language } from '@/lib/types'
|
||||
import { Page, MatrixGlyph, COLORS, Callout, Glyph } from './PrintLayout'
|
||||
import {
|
||||
EXTENDED_COMPETITORS,
|
||||
ALL_FEATURES,
|
||||
APPSEC_COMPETITORS,
|
||||
APPSEC_FEATURES,
|
||||
GROUP_LABELS,
|
||||
DACH_NOTE,
|
||||
} from '@/components/slides/CompetitionSlide.data'
|
||||
|
||||
interface SlideBase { lang: Language; pageNum: number; totalPages: number; versionName: string }
|
||||
|
||||
/* ===== COMPETITION, PAGE 1: Compliance space ===== */
|
||||
|
||||
export function PrintCompetitionPage1({ lang, pageNum, totalPages, versionName }: SlideBase) {
|
||||
const de = lang === 'de'
|
||||
// group features by category
|
||||
const grouped = ALL_FEATURES.reduce<Record<string, typeof ALL_FEATURES>>((acc, f) => {
|
||||
const g = f.group || 'other'
|
||||
if (!acc[g]) acc[g] = []
|
||||
acc[g].push(f)
|
||||
return acc
|
||||
}, {})
|
||||
const groupOrder = ['code-security', 'ai-data', 'frameworks', 'documentation', 'operations', 'platform', 'industry']
|
||||
|
||||
const competitorCols = ['vanta', 'drata', 'sprinto', 'proliance', 'dataguard', 'heydata'] as const
|
||||
type CC = typeof competitorCols[number]
|
||||
const competitorLabels: Record<CC, string> = {
|
||||
vanta: 'V', drata: 'D', sprinto: 'S', proliance: 'P', dataguard: 'DG', heydata: 'H',
|
||||
}
|
||||
|
||||
return (
|
||||
<Page kicker="12" section={de ? 'WETTBEWERB · 1 / 2, COMPLIANCE' : 'COMPETITION · 1 / 2, COMPLIANCE'} title={de ? 'Compliance-Markt: keiner kombiniert DSGVO + Code-Security + Self-Hosted KI.' : 'Compliance market: no one combines GDPR + code security + self-hosted AI.'} subtitle={de ? '6 globale/lokale Wettbewerber × 45 Features. ★ markiert eindeutige BreakPilot-USPs.' : '6 global/local competitors × 45 features. ★ marks unique BreakPilot USPs.'} pageNum={pageNum} totalPages={totalPages} versionName={versionName} footnote={de ? DACH_NOTE.de : DACH_NOTE.en}>
|
||||
|
||||
{/* Competitor profile table */}
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '7.5pt', fontVariantNumeric: 'tabular-nums', marginBottom: '4mm' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
{[de ? 'Wettbewerber' : 'Competitor', 'HQ', de ? 'Gegr.' : 'Founded', 'MA', 'ARR', de ? 'Kunden' : 'Customers', de ? 'Funding' : 'Funding', 'AI', 'Pricing'].map((h, i) => (
|
||||
<th key={i} style={{ background: COLORS.indigo50, color: COLORS.indigo700, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.06em', fontSize: '6.5pt', padding: '1.5mm 2mm', textAlign: i >= 2 ? 'right' : 'left', borderBottom: `1px solid ${COLORS.slate300}`, WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{EXTENDED_COMPETITORS.map((c, i) => (
|
||||
<tr key={c.name} style={{ background: i % 2 === 1 ? COLORS.slate50 : 'transparent', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
<td style={{ padding: '1.5mm 2mm', fontWeight: 700, color: COLORS.slate900 }}>{c.flag} {c.name}</td>
|
||||
<td style={{ padding: '1.5mm 2mm', color: COLORS.slate700 }}>{c.hqCountry}</td>
|
||||
<td style={{ padding: '1.5mm 2mm', textAlign: 'right', color: COLORS.slate700 }}>{c.founded}</td>
|
||||
<td style={{ padding: '1.5mm 2mm', textAlign: 'right', color: COLORS.slate700 }}>{c.employees.toLocaleString('de-DE')}</td>
|
||||
<td style={{ padding: '1.5mm 2mm', textAlign: 'right', color: COLORS.slate800, fontWeight: 600 }}>{c.revenue}</td>
|
||||
<td style={{ padding: '1.5mm 2mm', textAlign: 'right', color: COLORS.slate700 }}>{c.customers.toLocaleString('de-DE')}</td>
|
||||
<td style={{ padding: '1.5mm 2mm', textAlign: 'right', color: COLORS.slate700, fontSize: '6.5pt' }}>{c.fundingTotal}</td>
|
||||
<td style={{ padding: '1.5mm 2mm', textAlign: 'right' }}>
|
||||
<MatrixGlyph v={c.aiUsage === 'full' ? true : c.aiUsage === 'partial' ? 'partial' : false} />
|
||||
</td>
|
||||
<td style={{ padding: '1.5mm 2mm', textAlign: 'right', color: COLORS.slate700, fontSize: '6.5pt' }}>{c.pricing}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* Feature matrix */}
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '6.5pt', fontVariantNumeric: 'tabular-nums' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ background: COLORS.slate900, color: '#ffffff', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.06em', fontSize: '6pt', padding: '1.2mm 2mm', textAlign: 'left', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>{de ? 'Feature (45)' : 'Feature (45)'}</th>
|
||||
<th style={{ background: COLORS.indigo600, color: '#ffffff', fontWeight: 700, fontSize: '6pt', padding: '1.2mm 1mm', textAlign: 'center', width: '7mm', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>BP</th>
|
||||
{competitorCols.map(k => (
|
||||
<th key={k} style={{ background: COLORS.slate800, color: '#ffffff', fontWeight: 700, fontSize: '6pt', padding: '1.2mm 1mm', textAlign: 'center', width: '7mm', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>{competitorLabels[k]}</th>
|
||||
))}
|
||||
<th style={{ background: COLORS.slate100, color: COLORS.slate700, fontWeight: 700, fontSize: '5.5pt', padding: '1.2mm 1mm', textAlign: 'center', width: '12mm', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>USP</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{groupOrder.flatMap(g => {
|
||||
const groupLabel = GROUP_LABELS[g]
|
||||
const features = grouped[g] || []
|
||||
if (!features.length) return []
|
||||
return [
|
||||
<tr key={`g-${g}`}>
|
||||
<td colSpan={9} style={{ background: COLORS.slate100, padding: '1mm 2mm', fontSize: '5.5pt', fontWeight: 700, color: COLORS.slate600, textTransform: 'uppercase', letterSpacing: '0.1em', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
{de ? groupLabel.de : groupLabel.en}
|
||||
</td>
|
||||
</tr>,
|
||||
...features.map((f, i) => (
|
||||
<tr key={`${g}-${i}`} style={{ background: i % 2 === 1 ? COLORS.slate50 : 'transparent', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
<td style={{ padding: '0.7mm 2mm', color: COLORS.slate800, fontSize: '6.5pt', lineHeight: 1.25 }}>{de ? f.de : f.en}</td>
|
||||
<td style={{ padding: '0.7mm 1mm', textAlign: 'center' }}><MatrixGlyph v={f.bp} isUSP={f.isUSP} /></td>
|
||||
{competitorCols.map(k => (
|
||||
<td key={k} style={{ padding: '0.7mm 1mm', textAlign: 'center' }}><MatrixGlyph v={f[k] as Glyph} /></td>
|
||||
))}
|
||||
<td style={{ padding: '0.7mm 1mm', textAlign: 'center', fontSize: '6pt', color: COLORS.indigo600, fontWeight: 700 }}>{f.isUSP ? '★' : ''}</td>
|
||||
</tr>
|
||||
)),
|
||||
]
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div style={{ flexShrink: 0, marginTop: '2mm', display: 'flex', gap: '6mm', fontSize: '6.5pt', color: COLORS.slate500 }}>
|
||||
<span><MatrixGlyph v={true} /> {de ? 'voll' : 'full'}</span>
|
||||
<span><MatrixGlyph v="partial" /> {de ? 'teilw.' : 'partial'}</span>
|
||||
<span><MatrixGlyph v={false} /> {de ? 'fehlt' : 'missing'}</span>
|
||||
<span>★ <span style={{ color: COLORS.indigo600, fontWeight: 700 }}>{de ? 'BreakPilot-USP' : 'BreakPilot USP'}</span></span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<span>V=Vanta · D=Drata · S=Sprinto · P=Proliance · DG=DataGuard · H=heyData</span>
|
||||
</div>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
/* ===== COMPETITION, PAGE 2: AppSec space ===== */
|
||||
|
||||
export function PrintCompetitionPage2({ lang, pageNum, totalPages, versionName }: SlideBase) {
|
||||
const de = lang === 'de'
|
||||
|
||||
return (
|
||||
<Page kicker="12" section={de ? 'WETTBEWERB · 2 / 2, APPSEC' : 'COMPETITION · 2 / 2, APPSEC'} title={de ? 'Cyber-Security: BreakPilot ersetzt das ganze AppSec-Stack.' : 'Cyber Security: BreakPilot replaces the entire AppSec stack.'} subtitle={de ? 'Acht etablierte Code-Security-Anbieter, jeder mit einer Disziplin. BreakPilot vereint sie auf einer EU-souveränen Plattform, zum Bruchteil der Kosten.' : 'Eight established code-security vendors, each with one discipline. BreakPilot combines them on one EU-sovereign platform, at a fraction of the cost.'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
|
||||
|
||||
{/* Competitor profile table */}
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '7.5pt', fontVariantNumeric: 'tabular-nums', marginBottom: '4mm' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
{[de ? 'Anbieter' : 'Vendor', 'HQ', de ? 'Gegr.' : 'Founded', 'MA', 'ARR', de ? 'Kunden' : 'Customers', de ? 'Pricing' : 'Pricing', de ? 'Fokus' : 'Focus'].map((h, i) => (
|
||||
<th key={i} style={{ background: COLORS.indigo50, color: COLORS.indigo700, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.06em', fontSize: '6.5pt', padding: '1.5mm 2mm', textAlign: i >= 2 && i <= 6 ? 'right' : 'left', borderBottom: `1px solid ${COLORS.slate300}`, WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{APPSEC_COMPETITORS.map((c, i) => (
|
||||
<tr key={c.name} style={{ background: i % 2 === 1 ? COLORS.slate50 : 'transparent', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
<td style={{ padding: '1.5mm 2mm', fontWeight: 700, color: COLORS.slate900 }}>{c.flag} {c.name}</td>
|
||||
<td style={{ padding: '1.5mm 2mm', color: COLORS.slate700 }}>{c.hq}</td>
|
||||
<td style={{ padding: '1.5mm 2mm', textAlign: 'right', color: COLORS.slate700 }}>{c.founded}</td>
|
||||
<td style={{ padding: '1.5mm 2mm', textAlign: 'right', color: COLORS.slate700 }}>{c.employees.toLocaleString('de-DE')}</td>
|
||||
<td style={{ padding: '1.5mm 2mm', textAlign: 'right', color: COLORS.slate800, fontWeight: 600 }}>{c.revenue}</td>
|
||||
<td style={{ padding: '1.5mm 2mm', textAlign: 'right', color: COLORS.slate700, fontSize: '6.5pt' }}>{c.customers}</td>
|
||||
<td style={{ padding: '1.5mm 2mm', textAlign: 'right', color: COLORS.slate700, fontSize: '6.5pt' }}>{c.pricing}</td>
|
||||
<td style={{ padding: '1.5mm 2mm', color: COLORS.slate600, fontSize: '6.5pt', lineHeight: 1.3 }}>{de ? c.focus.de : c.focus.en}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* Feature matrix */}
|
||||
<div style={{ flex: 1, minHeight: 0 }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '7pt', fontVariantNumeric: 'tabular-nums' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ background: COLORS.slate900, color: '#ffffff', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.06em', fontSize: '6.5pt', padding: '1.5mm 2mm', textAlign: 'left', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>Feature</th>
|
||||
<th style={{ background: COLORS.indigo600, color: '#ffffff', fontWeight: 700, fontSize: '6.5pt', padding: '1.5mm 1mm', textAlign: 'center', width: '8%', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>BP</th>
|
||||
{['Snyk', 'Veracode', 'Checkmarx', 'Sonar', 'Semgrep', 'Pentera', 'Invicti', 'Intruder'].map(name => (
|
||||
<th key={name} style={{ background: COLORS.slate800, color: '#ffffff', fontWeight: 700, fontSize: '6pt', padding: '1.5mm 1mm', textAlign: 'center', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>{name}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{APPSEC_FEATURES.map((f, i) => (
|
||||
<tr key={i} style={{ background: i % 2 === 1 ? COLORS.slate50 : 'transparent', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
<td style={{ padding: '1.5mm 2mm', color: COLORS.slate800, fontSize: '7pt', lineHeight: 1.3, fontWeight: 500 }}>{de ? f.de : f.en}</td>
|
||||
<td style={{ padding: '1.5mm 1mm', textAlign: 'center' }}><MatrixGlyph v={f.bp} /></td>
|
||||
<td style={{ padding: '1.5mm 1mm', textAlign: 'center' }}><MatrixGlyph v={f.snyk} /></td>
|
||||
<td style={{ padding: '1.5mm 1mm', textAlign: 'center' }}><MatrixGlyph v={f.veracode} /></td>
|
||||
<td style={{ padding: '1.5mm 1mm', textAlign: 'center' }}><MatrixGlyph v={f.checkmarx} /></td>
|
||||
<td style={{ padding: '1.5mm 1mm', textAlign: 'center' }}><MatrixGlyph v={f.sonar} /></td>
|
||||
<td style={{ padding: '1.5mm 1mm', textAlign: 'center' }}><MatrixGlyph v={f.semgrep} /></td>
|
||||
<td style={{ padding: '1.5mm 1mm', textAlign: 'center' }}><MatrixGlyph v={f.pentera} /></td>
|
||||
<td style={{ padding: '1.5mm 1mm', textAlign: 'center' }}><MatrixGlyph v={f.invicti} /></td>
|
||||
<td style={{ padding: '1.5mm 1mm', textAlign: 'center' }}><MatrixGlyph v={f.intruder} /></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '5mm', flexShrink: 0 }}>
|
||||
<Callout tone="accent" label={de ? 'Was uns differenziert' : 'What differentiates us'}>
|
||||
{de
|
||||
? 'AppSec-Tools liefern Findings. BreakPilot liefert Findings + Auto-Fix-Vorschläge + Compliance-Dokumentation + Audit-Trail, alles auf einer EU-souveränen Plattform. Snyk + Vanta zusammen kosten 4-6× mehr und bleiben US-gehostet.'
|
||||
: 'AppSec tools deliver findings. BreakPilot delivers findings + auto-fix proposals + compliance documentation + audit trail, all on one EU-sovereign platform. Snyk + Vanta together cost 4-6× more and remain US-hosted.'}
|
||||
</Callout>
|
||||
</div>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
@@ -1,301 +0,0 @@
|
||||
import { PrintPage, SectionTitle, PrintTable, Badge, COLORS } from './PrintLayout'
|
||||
import { Language, PitchCompany, PitchFunding, PitchProduct, PitchMarket, PitchTeamMember, PitchMilestone } from '@/lib/types'
|
||||
|
||||
const DE_PROBLEM_CARDS = [
|
||||
{ title: 'KI-Dilemma', stat: 'Abgehängt', desc: 'Produzierende Unternehmen brauchen KI, um wettbewerbsfähig zu bleiben. Aber US-KI an den eigenen Quellcode und die Konstruktionsdaten zu lassen, kommt für die meisten nicht in Frage. Wer auf US-KI verzichtet, verliert den Anschluss. Wer sie nutzt, riskiert seine Datensouveränität und verstößt gegen europäisches Recht.' },
|
||||
{ title: 'Patriot Act + FISA 702', stat: 'Kein Schutz', desc: 'Selbst wer EU-Server bei AWS, Google oder Microsoft bucht, ist nicht geschützt. US-Gesetze wie FISA 702 und der Cloud Act gelten extraterritorial — US-Behörden können auf Daten zugreifen, egal wo der Server steht. Das Schrems-II-Urteil des EuGH hat das bestätigt.' },
|
||||
{ title: 'Regulierungs-Tsunami', stat: 'Nicht tragbar', desc: 'Seit 2024 greifen AI Act, NIS2 und Cyber Resilience Act — zusätzlich zu DSGVO, Data Act, Maschinenverordnung und Lieferkettengesetz. Europäische Unternehmen tragen Compliance-Kosten, die US- und Asien-Konkurrenten nicht haben. KMU können das nicht mehr allein stemmen.' },
|
||||
]
|
||||
const EN_PROBLEM_CARDS = [
|
||||
{ title: 'AI Dilemma', stat: 'Left Behind', desc: 'Manufacturing companies need AI to stay competitive. But letting US AI access their source code and engineering data is out of the question for most. Those avoiding US AI fall behind. Those using it risk their data sovereignty and may violate European law.' },
|
||||
{ title: 'Patriot Act + FISA 702', stat: 'No Protection', desc: 'Even booking EU servers at AWS, Google or Microsoft offers no protection. US laws like FISA 702 and the Cloud Act apply extraterritorially — US authorities can access data regardless of server location. The Schrems II ruling by the CJEU confirmed this.' },
|
||||
{ title: 'Regulation Tsunami', stat: 'Unsustainable', desc: 'Since 2024, the AI Act, NIS2 and Cyber Resilience Act apply — on top of GDPR, Data Act, Machinery Regulation and Supply Chain Act. European companies bear compliance costs that US and Asian competitors do not face. SMEs can no longer handle this alone.' },
|
||||
]
|
||||
|
||||
const DE_PILLARS = [
|
||||
{ title: 'Kontinuierliche Code-Security', desc: 'SAST, DAST, SBOM und Pentesting bei jeder Code-Änderung — nicht einmal im Jahr. Findings direkt als Tickets im Issue-Tracker deiner Wahl, mit Implementierungsvorschlägen. 15.000+ EUR pro Jahr und Anwendung an Pentest-Kosten gespart. Kein manueller Aufwand, keine vergessenen Schwachstellen.' },
|
||||
{ title: 'Compliance auf Autopilot', desc: 'VVT, TOMs, DSFA, Löschfristen, CE-Risikobeurteilung automatisch generiert. Nach dem Audit: Haupt- und Nebenabweichungen End-to-End — Rollen zuweisen, Stichtage, Tickets, Nachweise einfordern, Eskalation an GF. Kein Excel, kein Hinterherlaufen, kein Stressaudit.' },
|
||||
{ title: 'Deutsche Cloud, volle Integration', desc: 'BSI-zertifizierte Cloud in Deutschland. Live-Support über Jitsi (Video) und Matrix (Chat). Keine US-SaaS im Source Code — DSGVO-konform by design. Optional: Mac Mini/Studio für maximale Privacy bei Kleinstunternehmen. Nahtlose Integration in bestehende Workflows.' },
|
||||
]
|
||||
const EN_PILLARS = [
|
||||
{ title: 'Continuous Code Security', desc: 'SAST, DAST, SBOM and pentesting on every code change — not once a year. Findings as tickets in the issue tracker of your choice, with implementation suggestions. EUR 15,000+ per year per application in pentest costs saved. No manual effort, no forgotten vulnerabilities.' },
|
||||
{ title: 'Compliance on Autopilot', desc: 'RoPA, TOMs, DPIA, retention policies, CE risk assessment generated automatically. Post-audit: major and minor deviations end-to-end — role assignment, deadlines, tickets, evidence collection, escalation to management. No Excel, no chasing, no stress audits.' },
|
||||
{ title: 'German Cloud, Full Integration', desc: 'BSI-certified cloud in Germany. Live support via Jitsi (video) and Matrix (chat). No US SaaS in source code — GDPR compliant by design. Optional: Mac Mini/Studio for maximum privacy for micro businesses. Seamless integration into existing workflows.' },
|
||||
]
|
||||
|
||||
function fmtEur(n: number) { return n.toLocaleString('de-DE', { maximumFractionDigits: 0 }) + ' EUR' }
|
||||
|
||||
interface SlideBase { lang: Language; pageNum: number; totalPages: number; versionName: string }
|
||||
|
||||
export function PrintCoverPage({ company, funding, versionName, lang }: { company: PitchCompany; funding: PitchFunding; lang: Language; versionName: string }) {
|
||||
const de = lang === 'de'
|
||||
const instrument = funding?.instrument || 'Pre-Seed'
|
||||
return (
|
||||
<div className="print-page-break">
|
||||
<div className="print-page" style={{ width: '297mm', height: '210mm', backgroundColor: '#ffffff', display: 'flex', flexDirection: 'column', fontFamily: 'system-ui, -apple-system, sans-serif', boxSizing: 'border-box', margin: '0 auto 32px', boxShadow: '0 4px 24px rgba(0,0,0,0.12)', overflow: 'hidden' }}>
|
||||
<div style={{ height: '80px', background: 'linear-gradient(135deg, #4f46e5, #7c3aed)', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact', display: 'flex', alignItems: 'center', padding: '0 32px', gap: '16px', flexShrink: 0 }}>
|
||||
<span style={{ color: '#fff', fontWeight: 800, fontSize: '30px', letterSpacing: '-0.01em' }}>BreakPilot</span>
|
||||
<span style={{ color: 'rgba(255,255,255,0.55)', fontWeight: 400, fontSize: '15px' }}>
|
||||
{de ? 'Compliance & Code-Security' : 'Compliance & Code Security'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', padding: '28px 32px' }}>
|
||||
<p style={{ fontSize: '12px', color: COLORS.indigo, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '10px', margin: '0 0 10px' }}>
|
||||
{instrument} · {versionName}
|
||||
</p>
|
||||
<h1 style={{ fontSize: '40px', fontWeight: 800, color: COLORS.dark, lineHeight: 1.1, margin: '0 0 12px' }}>
|
||||
{company?.name || 'BreakPilot'}
|
||||
</h1>
|
||||
<p style={{ fontSize: '16px', color: COLORS.med, maxWidth: '400px', lineHeight: 1.55, margin: '0 0 32px' }}>
|
||||
{de ? (company?.tagline_de || 'Kontinuierliche Compliance für europäische Unternehmen.') : (company?.tagline_en || 'Continuous compliance for European companies.')}
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: '40px' }}>
|
||||
{([
|
||||
[de ? 'Gegründet' : 'Founded', company?.founding_date ? new Date(company.founding_date).getFullYear().toString() : 'Aug 2026'],
|
||||
[de ? 'Standort' : 'HQ', company?.hq_city || 'Bodman-Ludwigshafen'],
|
||||
[de ? 'Instrument' : 'Instrument', instrument],
|
||||
[de ? 'Runde' : 'Round', funding?.round_name || 'Pre-Seed'],
|
||||
] as [string, string][]).map(([label, val]) => (
|
||||
<div key={label}>
|
||||
<p style={{ fontSize: '9px', color: COLORS.light, textTransform: 'uppercase', letterSpacing: '0.06em', margin: '0 0 3px' }}>{label}</p>
|
||||
<p style={{ fontSize: '14px', fontWeight: 700, color: COLORS.dark, margin: 0 }}>{val}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ height: '40px', backgroundColor: '#f5f3ff', borderTop: `1px solid ${COLORS.border}`, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
<span style={{ fontSize: '10px', color: COLORS.indigo, fontWeight: 600, letterSpacing: '0.08em', textTransform: 'uppercase' }}>
|
||||
{de ? 'Vertraulich — Nur für Investoren' : 'Confidential — For Investor Use Only'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function PrintProblemPage({ lang, pageNum, totalPages, versionName }: SlideBase) {
|
||||
const de = lang === 'de'
|
||||
const cards = de ? DE_PROBLEM_CARDS : EN_PROBLEM_CARDS
|
||||
return (
|
||||
<PrintPage title={de ? 'Das Problem' : 'The Problem'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
|
||||
<SectionTitle subtitle={de ? 'Europäische Unternehmen im Dilemma' : 'European companies in a dilemma'}>
|
||||
{de ? 'Das Problem' : 'The Problem'}
|
||||
</SectionTitle>
|
||||
<div style={{ display: 'flex', gap: '12px', flex: 1 }}>
|
||||
{cards.map((c) => (
|
||||
<div key={c.title} style={{ flex: 1, border: `1px solid ${COLORS.border}`, borderRadius: '10px', padding: '16px', borderTop: `3px solid ${COLORS.indigo}`, display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px', flexShrink: 0 }}>
|
||||
<span style={{ fontSize: '9px', fontWeight: 700, color: '#fff', background: COLORS.indigo, padding: '3px 9px', borderRadius: '99px', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>{c.stat}</span>
|
||||
<span style={{ fontSize: '13px', fontWeight: 700, color: COLORS.dark }}>{c.title}</span>
|
||||
</div>
|
||||
<p style={{ fontSize: '11px', color: COLORS.med, lineHeight: 1.6, margin: 0, flex: 1 }}>{c.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ marginTop: '14px', padding: '12px 16px', background: '#f5f3ff', borderRadius: '8px', borderLeft: `3px solid ${COLORS.indigo}`, flexShrink: 0, WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
<p style={{ fontSize: '11px', fontStyle: 'italic', color: COLORS.med, margin: 0, lineHeight: 1.55 }}>
|
||||
{de ? '„Produzierende Unternehmen brauchen eine KI-Lösung, die in Europa läuft, ihren Code schützt und Compliance automatisiert — ohne ihre Daten an US-Konzerne zu geben."' : '"Manufacturing companies need an AI solution that runs in Europe, protects their code and automates compliance — without giving their data to US corporations."'}
|
||||
</p>
|
||||
</div>
|
||||
</PrintPage>
|
||||
)
|
||||
}
|
||||
|
||||
export function PrintSolutionPage({ lang, pageNum, totalPages, versionName }: SlideBase) {
|
||||
const de = lang === 'de'
|
||||
const pillars = de ? DE_PILLARS : EN_PILLARS
|
||||
const icons = ['⬡', '◎', '▦']
|
||||
return (
|
||||
<PrintPage title={de ? 'Die Lösung' : 'The Solution'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
|
||||
<SectionTitle subtitle={de ? 'Kontinuierliche Software-Compliance statt jährlicher Stichproben' : 'Continuous software compliance instead of annual spot checks'}>
|
||||
{de ? 'Die Lösung' : 'The Solution'}
|
||||
</SectionTitle>
|
||||
<div style={{ display: 'flex', gap: '12px', flex: 1 }}>
|
||||
{pillars.map((p, i) => (
|
||||
<div key={p.title} style={{ flex: 1, border: `1px solid ${COLORS.border}`, borderRadius: '10px', padding: '16px', display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ width: '36px', height: '36px', borderRadius: '8px', background: COLORS.indigoLight, display: 'flex', alignItems: 'center', justifyContent: 'center', marginBottom: '12px', fontSize: '18px', color: COLORS.indigo, flexShrink: 0, WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
{icons[i]}
|
||||
</div>
|
||||
<h3 style={{ fontSize: '13px', fontWeight: 700, color: COLORS.dark, marginBottom: '10px', marginTop: 0, flexShrink: 0 }}>{p.title}</h3>
|
||||
<p style={{ fontSize: '11px', color: COLORS.med, lineHeight: 1.6, margin: 0, flex: 1 }}>{p.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ marginTop: '14px', display: 'flex', gap: '8px', flexShrink: 0 }}>
|
||||
{(de
|
||||
? ['BSI-Cloud DE', 'DSGVO-konform', 'Kein US-SaaS', 'Kontinuierlich, nicht einmal/Jahr']
|
||||
: ['BSI Cloud DE', 'GDPR Compliant', 'No US SaaS', 'Continuous, not once/year']
|
||||
).map(tag => <Badge key={tag}>{tag}</Badge>)}
|
||||
</div>
|
||||
</PrintPage>
|
||||
)
|
||||
}
|
||||
|
||||
export function PrintProductPage({ products, lang, pageNum, totalPages, versionName }: SlideBase & { products: PitchProduct[] }) {
|
||||
const de = lang === 'de'
|
||||
const headers = [de ? 'Produkt' : 'Product', de ? 'Preis/Monat' : 'Price/Month', 'Hardware', de ? 'Key Features' : 'Key Features']
|
||||
const rows = products.map(p => [
|
||||
<span key="n" style={{ fontWeight: 600 }}>{p.name}{p.is_popular ? <span style={{ marginLeft: 6, fontSize: '8px', color: COLORS.indigo, fontWeight: 700 }}>★ Beliebt</span> : null}</span>,
|
||||
fmtEur(p.monthly_price_eur),
|
||||
p.hardware || '—',
|
||||
(de ? p.features_de : p.features_en)?.slice(0, 3).join(', ') || '—',
|
||||
])
|
||||
return (
|
||||
<PrintPage title={de ? 'Modularer Baukasten' : 'Modular Toolkit'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
|
||||
<SectionTitle subtitle={de ? 'Kunden wählen die Module, die sie brauchen' : 'Customers choose the modules they need'}>
|
||||
{de ? 'Produkte & Pricing' : 'Products & Pricing'}
|
||||
</SectionTitle>
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'space-between' }}>
|
||||
<PrintTable headers={headers} rows={rows} colWidths={['22%', '16%', '20%', '42%']} />
|
||||
<div style={{ padding: '14px 16px', background: '#f0fdf4', borderRadius: '8px', border: '1px solid #bbf7d0', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
<p style={{ fontSize: '12px', color: '#166534', margin: 0, fontWeight: 500 }}>
|
||||
{de
|
||||
? '💡 Kunden zahlen ~50.000 EUR/Jahr und sparen >50.000 EUR (Pentests + CE-Beurteilungen + Auditmanager). ROI ab Tag 1.'
|
||||
: '💡 Customers pay ~EUR 50,000/year and save >EUR 50,000 (pentests + CE assessments + audit managers). ROI from day 1.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</PrintPage>
|
||||
)
|
||||
}
|
||||
|
||||
export function PrintMarketPage({ market, lang, pageNum, totalPages, versionName }: SlideBase & { market: PitchMarket[] }) {
|
||||
const de = lang === 'de'
|
||||
const headers = [de ? 'Segment' : 'Segment', de ? 'Marktbeschreibung' : 'Description', de ? 'Volumen' : 'Volume', de ? 'Wachstum p.a.' : 'Growth p.a.', de ? 'Quelle' : 'Source']
|
||||
const rows = market.map(m => [
|
||||
<span key="s" style={{ fontWeight: 700, color: COLORS.indigo, fontSize: '11px' }}>{m.market_segment.toUpperCase()}</span>,
|
||||
m.label,
|
||||
fmtEur(m.value_eur),
|
||||
`${m.growth_rate_pct}%`,
|
||||
m.source,
|
||||
])
|
||||
return (
|
||||
<PrintPage title={de ? 'Marktchance' : 'Market Opportunity'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
|
||||
<SectionTitle subtitle={de ? 'Compliance & Code-Security für produzierende Unternehmen' : 'Compliance & Code Security for manufacturing companies'}>
|
||||
{de ? 'Marktchance' : 'Market Opportunity'}
|
||||
</SectionTitle>
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'space-between' }}>
|
||||
<PrintTable headers={headers} rows={rows} colWidths={['10%', '35%', '18%', '14%', '23%']} />
|
||||
<div style={{ display: 'flex', gap: '12px' }}>
|
||||
{market.map(m => (
|
||||
<div key={m.market_segment} style={{ flex: 1, padding: '14px', background: COLORS.indigoLight, borderRadius: '8px', textAlign: 'center', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
<p style={{ fontSize: '10px', color: COLORS.light, margin: '0 0 4px', textTransform: 'uppercase', letterSpacing: '0.06em' }}>{m.market_segment}</p>
|
||||
<p style={{ fontSize: '20px', fontWeight: 800, color: COLORS.indigo, margin: '0 0 2px' }}>{fmtEur(m.value_eur)}</p>
|
||||
<p style={{ fontSize: '10px', color: COLORS.med, margin: 0 }}>{m.growth_rate_pct}% p.a.</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</PrintPage>
|
||||
)
|
||||
}
|
||||
|
||||
export function PrintTeamPage({ team, lang, pageNum, totalPages, versionName }: SlideBase & { team: PitchTeamMember[] }) {
|
||||
const de = lang === 'de'
|
||||
return (
|
||||
<PrintPage title={de ? 'Das Team' : 'The Team'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
|
||||
<SectionTitle subtitle={de ? 'Gründer mit Domain-Expertise' : 'Founders with domain expertise'}>
|
||||
{de ? 'Das Team' : 'The Team'}
|
||||
</SectionTitle>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '12px', flex: 1 }}>
|
||||
{team.slice(0, 4).map(m => (
|
||||
<div key={m.id} style={{ border: `1px solid ${COLORS.border}`, borderRadius: '10px', padding: '16px', borderLeft: `3px solid ${COLORS.indigo}`, display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '10px', flexShrink: 0 }}>
|
||||
<div>
|
||||
<p style={{ fontSize: '15px', fontWeight: 700, color: COLORS.dark, margin: 0 }}>{m.name}</p>
|
||||
<p style={{ fontSize: '11px', color: COLORS.indigo, margin: '3px 0 0', fontWeight: 600 }}>{de ? m.role_de : m.role_en}</p>
|
||||
</div>
|
||||
{m.equity_pct > 0 && <Badge>{m.equity_pct}%</Badge>}
|
||||
</div>
|
||||
<p style={{ fontSize: '11px', color: COLORS.med, lineHeight: 1.6, margin: 0, flex: 1, overflow: 'hidden' }}>
|
||||
{de ? m.bio_de : m.bio_en}
|
||||
</p>
|
||||
{m.expertise?.length > 0 && (
|
||||
<div style={{ marginTop: '10px', display: 'flex', flexWrap: 'wrap', gap: '4px', flexShrink: 0 }}>
|
||||
{m.expertise.slice(0, 5).map(e => <Badge key={e} color="#6b7280">{e}</Badge>)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</PrintPage>
|
||||
)
|
||||
}
|
||||
|
||||
export function PrintMilestonesPage({ milestones, lang, pageNum, totalPages, versionName }: SlideBase & { milestones: PitchMilestone[] }) {
|
||||
const de = lang === 'de'
|
||||
const ordered = [...milestones].sort((a, b) => {
|
||||
const order = { completed: 0, in_progress: 1, planned: 2 }
|
||||
return (order[a.status] - order[b.status]) || new Date(a.milestone_date).getTime() - new Date(b.milestone_date).getTime()
|
||||
}).slice(0, 10)
|
||||
const statusColor = (s: string) => ({ completed: '#16a34a', in_progress: '#d97706', planned: '#94a3b8' }[s] || '#94a3b8')
|
||||
const statusLabel = (s: string) => de
|
||||
? ({ completed: 'Abgeschlossen', in_progress: 'In Arbeit', planned: 'Geplant' }[s] || s)
|
||||
: ({ completed: 'Completed', in_progress: 'In Progress', planned: 'Planned' }[s] || s)
|
||||
const fmtDate = (d: string) => new Date(d).toLocaleDateString(de ? 'de-DE' : 'en-GB', { month: 'short', year: 'numeric' })
|
||||
const rows = ordered.map(m => [
|
||||
fmtDate(m.milestone_date),
|
||||
<span key="t" style={{ fontWeight: 500 }}>{de ? m.title_de : m.title_en}</span>,
|
||||
m.category,
|
||||
<span key="s" style={{ color: statusColor(m.status), fontWeight: 600 }}>{statusLabel(m.status)}</span>,
|
||||
])
|
||||
return (
|
||||
<PrintPage title={de ? 'Meilensteine' : 'Milestones'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
|
||||
<SectionTitle subtitle={de ? 'Was wir bereits erreicht haben — und was als Nächstes kommt' : 'What we have achieved — and what comes next'}>
|
||||
{de ? 'Meilensteine' : 'Milestones'}
|
||||
</SectionTitle>
|
||||
<div style={{ flex: 1 }}>
|
||||
<PrintTable headers={[de ? 'Datum' : 'Date', de ? 'Meilenstein' : 'Milestone', de ? 'Kategorie' : 'Category', de ? 'Status' : 'Status']} rows={rows} colWidths={['13%', '47%', '22%', '18%']} />
|
||||
</div>
|
||||
</PrintPage>
|
||||
)
|
||||
}
|
||||
|
||||
export function PrintTheAskPage({ funding, lang, pageNum, totalPages, versionName }: SlideBase & { funding: PitchFunding }) {
|
||||
const de = lang === 'de'
|
||||
const amount = Number(funding?.amount_eur) || 0
|
||||
const isWD = (funding?.instrument || '').toLowerCase() === 'wandeldarlehen'
|
||||
const targetDate = funding?.target_date ? (() => { const d = new Date(funding.target_date); return `Q${Math.ceil((d.getMonth()+1)/3)} ${d.getFullYear()}` })() : 'TBD'
|
||||
const uof = funding?.use_of_funds || []
|
||||
const amountLabel = amount >= 1_000_000 ? `${(amount / 1_000_000).toFixed(1)} Mio. EUR` : amount >= 1_000 ? `${Math.round(amount / 1_000)}k EUR` : `${amount} EUR`
|
||||
return (
|
||||
<PrintPage title="The Ask" pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
|
||||
<SectionTitle subtitle={de ? 'Pre-Seed Finanzierung' : 'Pre-Seed Funding'}>The Ask</SectionTitle>
|
||||
<div style={{ display: 'flex', gap: '32px', flex: 1 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', justifyContent: 'center', flexShrink: 0, minWidth: '200px' }}>
|
||||
<p style={{ fontSize: '48px', fontWeight: 800, color: COLORS.indigo, margin: 0, lineHeight: 1 }}>{amountLabel}</p>
|
||||
<div style={{ marginTop: '20px', display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{([
|
||||
[de ? 'Instrument' : 'Instrument', isWD ? 'Wandeldarlehen' : 'Equity'],
|
||||
[de ? 'Runde' : 'Round', funding?.round_name || 'Pre-Seed'],
|
||||
[de ? 'Zieldatum' : 'Target', targetDate],
|
||||
] as [string, string][]).map(([k, v]) => (
|
||||
<div key={k} style={{ display: 'flex', gap: '10px', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: '11px', color: COLORS.light, minWidth: '80px' }}>{k}</span>
|
||||
<span style={{ fontSize: '13px', fontWeight: 600, color: COLORS.dark }}>{v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
|
||||
<p style={{ fontSize: '11px', fontWeight: 700, color: COLORS.dark, textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: '14px', marginTop: 0 }}>
|
||||
{de ? 'Mittelverwendung' : 'Use of Funds'}
|
||||
</p>
|
||||
{uof.map(u => (
|
||||
<div key={u.category} style={{ marginBottom: '10px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '4px' }}>
|
||||
<span style={{ fontSize: '12px', color: COLORS.med }}>{de ? u.label_de : u.label_en}</span>
|
||||
<span style={{ fontSize: '13px', fontWeight: 700, color: COLORS.indigo }}>{u.percentage}%</span>
|
||||
</div>
|
||||
<div style={{ height: '8px', background: '#e0e7ff', borderRadius: '4px', overflow: 'hidden', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
<div style={{ height: '100%', width: `${u.percentage}%`, background: COLORS.indigo, borderRadius: '4px', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</PrintPage>
|
||||
)
|
||||
}
|
||||
@@ -3,23 +3,35 @@
|
||||
import { useEffect } from 'react'
|
||||
import { Language, PitchData, FMResult, FMAssumption } from '@/lib/types'
|
||||
import {
|
||||
PrintCoverPage, PrintProblemPage, PrintSolutionPage, PrintProductPage,
|
||||
PrintMarketPage, PrintTeamPage, PrintMilestonesPage, PrintTheAskPage,
|
||||
} from './PrintCoreSlides'
|
||||
PrintCoverPage, PrintExecSummaryPage1, PrintExecSummaryPage2,
|
||||
PrintProblemPage, PrintSolutionPage,
|
||||
} from './PrintIntroSlides'
|
||||
import {
|
||||
PrintFinancialsPage, PrintAssumptionsPage, PrintCapTablePage,
|
||||
PrintDisclaimerPage, aggregateAnnualRows,
|
||||
} from './PrintFinancialSlides'
|
||||
PrintUSPPage1, PrintUSPPage2, PrintRegulatoryLandscapePage,
|
||||
PrintProductPage, PrintHowItWorksPage, PrintBusinessModelPage,
|
||||
} from './PrintProductSlides'
|
||||
import {
|
||||
PrintExecutiveSummaryPage, PrintUSPPage, PrintRegulatoryLandscapePage,
|
||||
PrintHowItWorksPage, PrintBusinessModelPage, PrintCompetitionPage,
|
||||
PrintCustomerSavingsPage,
|
||||
} from './PrintExtraSlides'
|
||||
PrintMarketPage, PrintMilestonesPage, PrintTeamPage,
|
||||
PrintTheAskPage, PrintCustomerSavingsPage,
|
||||
} from './PrintMarketSlides'
|
||||
import {
|
||||
PrintStrategyPage, PrintFinanzplanPage, PrintRegulatoryPage,
|
||||
PrintArchitecturePage, PrintEngineeringPage, PrintAIPipelinePage,
|
||||
PrintRisksPage, PrintGlossaryPage,
|
||||
PrintCompetitionPage1, PrintCompetitionPage2,
|
||||
} from './PrintCompetitionSlides'
|
||||
import {
|
||||
PrintStrategyPage, PrintRegulatoryPage, PrintArchitecturePage,
|
||||
PrintEngineeringPage, PrintAIPipelinePage, PrintRisksPage, PrintGlossaryPage,
|
||||
} from './PrintAnnexSlides'
|
||||
import {
|
||||
PrintTLDRPage, PrintDifferentiatorsPage, PrintKPIHeroPage,
|
||||
PrintTechStackPage, PrintAnnexDividerPage,
|
||||
} from './PrintNewSlides'
|
||||
import {
|
||||
PrintFinanzplanPage1, PrintFinanzplanPage2, PrintAssumptionsPage,
|
||||
PrintFinancialsPage, PrintCapTablePage, PrintDisclaimerPage,
|
||||
aggregateAnnualRows,
|
||||
} from './PrintFinancialSlides'
|
||||
|
||||
export { aggregateAnnualRows }
|
||||
|
||||
interface PrintDeckProps {
|
||||
pitchData: PitchData
|
||||
@@ -31,46 +43,55 @@ interface PrintDeckProps {
|
||||
}
|
||||
|
||||
export default function PrintDeck({ pitchData, versionName, fmResults, fmAssumptions, financial, lang }: PrintDeckProps) {
|
||||
const isWandeldarlehen = (pitchData.funding?.instrument || '').toLowerCase() === 'wandeldarlehen'
|
||||
const isWandeldarlehen = (pitchData.funding?.instrument || '').toLowerCase().includes('wandeldarlehen') ||
|
||||
(pitchData.funding?.instrument || '').toLowerCase().includes('convertible')
|
||||
const hasCapTable = financial && !isWandeldarlehen
|
||||
const annualRows = aggregateAnnualRows(fmResults)
|
||||
const hasFinancials = financial && annualRows.length > 0
|
||||
// Standard = 25 slides. Financial adds: detailed financials page + (optional) cap-table.
|
||||
const totalPages = 25 + (hasFinancials ? 1 : 0) + (hasCapTable ? 1 : 0)
|
||||
const hasFinancialData = annualRows.length > 0
|
||||
const de = lang === 'de'
|
||||
|
||||
// Base standard PDF: 35 physical pages.
|
||||
// 2 (exec) + 1 (TL;DR) + 1 (cover) + 1 (problem) + 1 (solution) + 2 (usp) +
|
||||
// 1 (differentiators) + 1 (regL) + 1 (product) + 1 (how) + 1 (market) + 1 (bm) +
|
||||
// 1 (milestones) + 2 (competition) + 1 (team) + 1 (ask) + 1 (savings) +
|
||||
// 1 (anhang-divider) + 1 (strategy) + 1 (kpis) + 2 (finanzplan) + 1 (p&l detail) +
|
||||
// 1 (assumptions) + 1 (regulatory) + 1 (architecture) + 1 (engineering) +
|
||||
// 1 (tech-stack) + 1 (aipipeline) + 1 (risks) + 1 (glossary) + 1 (disclaimer) = 35
|
||||
// P&L detail is now in standard PDF (was financial-only); cap-table stays
|
||||
// financial-only (and is suppressed for Wandeldarlehen).
|
||||
const BASE_PAGES = 35
|
||||
const totalPages = BASE_PAGES + (hasCapTable ? 1 : 0)
|
||||
void hasFinancialData
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => window.print(), 900)
|
||||
return () => clearTimeout(t)
|
||||
}, [])
|
||||
|
||||
let n = 0
|
||||
function p() {
|
||||
n += 1
|
||||
return { lang, pageNum: n, totalPages, versionName }
|
||||
}
|
||||
const p = () => ({ lang, pageNum: ++n, totalPages, versionName })
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Toolbar — screen only */}
|
||||
{/* Toolbar, screen only */}
|
||||
<div className="no-print" style={{
|
||||
position: 'sticky', top: 0, zIndex: 100,
|
||||
background: '#1e1b4b', color: '#fff',
|
||||
background: '#0f172a', color: '#fff',
|
||||
padding: '10px 24px',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
fontFamily: 'system-ui, -apple-system, sans-serif',
|
||||
fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif",
|
||||
fontSize: '13px', boxShadow: '0 2px 8px rgba(0,0,0,0.3)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
||||
<span style={{ fontWeight: 700 }}>BreakPilot</span>
|
||||
<span style={{ opacity: 0.6, fontSize: '11px' }}>{versionName}</span>
|
||||
<span style={{ fontSize: '11px', padding: '2px 8px', borderRadius: '99px', background: financial ? '#7c3aed22' : '#6366f122', color: financial ? '#a78bfa' : '#818cf8' }}>
|
||||
{financial ? (de ? 'PDF + Finanzen' : 'PDF + Financial') : 'Standard PDF'}
|
||||
<span style={{ fontWeight: 800, letterSpacing: '-0.01em' }}>BreakPilot</span>
|
||||
<span style={{ opacity: 0.55, fontSize: '11px' }}>{versionName}</span>
|
||||
<span style={{ fontSize: '11px', padding: '2px 8px', borderRadius: '99px', background: financial ? '#4f46e533' : '#6366f133', color: financial ? '#a5b4fc' : '#a5b4fc', fontWeight: 600, letterSpacing: '0.02em' }}>
|
||||
{financial ? (de ? 'PDF + Finanzen' : 'PDF + Financial') : (de ? 'Standard PDF' : 'Standard PDF')}
|
||||
</span>
|
||||
<span style={{ fontSize: '11px', opacity: 0.5 }}>{totalPages} {de ? 'Seiten' : 'pages'}</span>
|
||||
<span style={{ fontSize: '11px', opacity: 0.5, fontVariantNumeric: 'tabular-nums' }}>{totalPages} {de ? 'Seiten' : 'pages'}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '10px' }}>
|
||||
<button onClick={() => window.print()} style={{ padding: '6px 16px', background: '#6366f1', color: '#fff', border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: 600, fontSize: '13px' }}>
|
||||
<button onClick={() => window.print()} style={{ padding: '6px 16px', background: '#4f46e5', color: '#fff', border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: 600, fontSize: '13px' }}>
|
||||
{de ? 'Drucken / Als PDF speichern' : 'Print / Save as PDF'}
|
||||
</button>
|
||||
<button onClick={() => window.close()} style={{ padding: '6px 12px', background: 'rgba(255,255,255,0.1)', color: '#fff', border: '1px solid rgba(255,255,255,0.2)', borderRadius: '6px', cursor: 'pointer', fontSize: '13px' }}>
|
||||
@@ -79,62 +100,109 @@ export default function PrintDeck({ pitchData, versionName, fmResults, fmAssumpt
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="print-deck-wrapper" style={{ padding: '32px 0' }}>
|
||||
{/* Slide order mirrors lib/slide-order.ts, minus intro-presenter, ai-qa, annex-sdk-demo. */}
|
||||
<div className="print-deck-wrapper" style={{ padding: '32px 0', fontFamily: "'Inter', system-ui, sans-serif" }}>
|
||||
{/* PITCH (slides 01–17) */}
|
||||
|
||||
{/* 1. executive-summary */}
|
||||
<PrintExecutiveSummaryPage market={pitchData.market || []} funding={pitchData.funding} {...p()} />
|
||||
{/* 2. cover (page 2 — uses its own layout; assign sequential number) */}
|
||||
{/* 01–02 executive-summary (2 pages) */}
|
||||
<PrintExecSummaryPage1 market={pitchData.market || []} {...p()} />
|
||||
<PrintExecSummaryPage2 {...p()} />
|
||||
|
||||
{/* 03 TL;DR — 30 Sekunden */}
|
||||
<PrintTLDRPage {...p()} />
|
||||
|
||||
{/* 04 cover */}
|
||||
{(() => { n += 1; return <PrintCoverPage company={pitchData.company} funding={pitchData.funding} versionName={versionName} lang={lang} /> })()}
|
||||
{/* 3. problem */}
|
||||
|
||||
{/* 05 problem */}
|
||||
<PrintProblemPage {...p()} />
|
||||
{/* 4. solution */}
|
||||
|
||||
{/* 06 solution */}
|
||||
<PrintSolutionPage {...p()} />
|
||||
{/* 5. usp */}
|
||||
<PrintUSPPage {...p()} />
|
||||
{/* 6. regulatory-landscape */}
|
||||
|
||||
{/* 07–08 usp (2 pages) */}
|
||||
<PrintUSPPage1 {...p()} />
|
||||
<PrintUSPPage2 {...p()} />
|
||||
|
||||
{/* 09 differentiators */}
|
||||
<PrintDifferentiatorsPage {...p()} />
|
||||
|
||||
{/* 10 regulatory-landscape */}
|
||||
<PrintRegulatoryLandscapePage {...p()} />
|
||||
{/* 7. product */}
|
||||
|
||||
{/* 11 product / modular-toolkit */}
|
||||
<PrintProductPage products={pitchData.products || []} {...p()} />
|
||||
{/* 8. how-it-works */}
|
||||
|
||||
{/* 12 how-it-works */}
|
||||
<PrintHowItWorksPage {...p()} />
|
||||
{/* 9. market */}
|
||||
|
||||
{/* 13 market */}
|
||||
<PrintMarketPage market={pitchData.market || []} {...p()} />
|
||||
{/* 10. business-model */}
|
||||
|
||||
{/* 14 business-model / pricing */}
|
||||
<PrintBusinessModelPage {...p()} />
|
||||
{/* 11. traction (uses milestones table) */}
|
||||
|
||||
{/* 15 traction (milestones) */}
|
||||
<PrintMilestonesPage milestones={pitchData.milestones || []} {...p()} />
|
||||
{/* 12. competition */}
|
||||
<PrintCompetitionPage {...p()} />
|
||||
{/* 13. team */}
|
||||
|
||||
{/* 16–17 competition (2 pages) */}
|
||||
<PrintCompetitionPage1 {...p()} />
|
||||
<PrintCompetitionPage2 {...p()} />
|
||||
|
||||
{/* 18 team */}
|
||||
<PrintTeamPage team={pitchData.team || []} {...p()} />
|
||||
{/* 14. the-ask */}
|
||||
|
||||
{/* 19 the-ask */}
|
||||
<PrintTheAskPage funding={pitchData.funding} {...p()} />
|
||||
{/* 15. customer-savings */}
|
||||
|
||||
{/* 20 customer-savings */}
|
||||
<PrintCustomerSavingsPage {...p()} />
|
||||
{/* 16. annex-strategy */}
|
||||
|
||||
{/* 21 ANHANG divider — chapter break before the appendix */}
|
||||
<PrintAnnexDividerPage {...p()} />
|
||||
|
||||
{/* APPENDIX (slides 22–35) */}
|
||||
|
||||
{/* 22 annex-strategy */}
|
||||
<PrintStrategyPage {...p()} />
|
||||
{/* 17. annex-finanzplan */}
|
||||
<PrintFinanzplanPage fmResults={fmResults} {...p()} />
|
||||
{/* Financial-only: detailed P&L table */}
|
||||
{hasFinancials && <PrintFinancialsPage annualRows={annualRows} {...p()} />}
|
||||
{/* 18. annex-assumptions */}
|
||||
|
||||
{/* 23 KPIs — 2026 → 2030 trajectory */}
|
||||
<PrintKPIHeroPage fmResults={fmResults} {...p()} />
|
||||
|
||||
{/* 24–25 annex-finanzplan (2 pages) */}
|
||||
<PrintFinanzplanPage1 fmResults={fmResults} {...p()} />
|
||||
<PrintFinanzplanPage2 fmResults={fmResults} {...p()} />
|
||||
|
||||
{/* 26 P&L detail (was financial-only; now standard) */}
|
||||
<PrintFinancialsPage annualRows={annualRows} {...p()} />
|
||||
|
||||
{/* 27 annex-assumptions */}
|
||||
<PrintAssumptionsPage assumptions={fmAssumptions} {...p()} />
|
||||
{/* 19. annex-regulatory */}
|
||||
|
||||
{/* 28 annex-regulatory */}
|
||||
<PrintRegulatoryPage {...p()} />
|
||||
{/* 20. annex-architecture */}
|
||||
|
||||
{/* 29 annex-architecture */}
|
||||
<PrintArchitecturePage {...p()} />
|
||||
{/* 21. annex-engineering */}
|
||||
|
||||
{/* 30 annex-engineering */}
|
||||
<PrintEngineeringPage {...p()} />
|
||||
{/* 22. annex-aipipeline */}
|
||||
|
||||
{/* 31 tech-stack */}
|
||||
<PrintTechStackPage {...p()} />
|
||||
|
||||
{/* 32 annex-aipipeline */}
|
||||
<PrintAIPipelinePage {...p()} />
|
||||
{/* 23. risks */}
|
||||
|
||||
{/* 33 risks */}
|
||||
<PrintRisksPage {...p()} />
|
||||
{/* 24. annex-glossary */}
|
||||
|
||||
{/* 34 annex-glossary */}
|
||||
<PrintGlossaryPage {...p()} />
|
||||
{/* Financial-only: cap table */}
|
||||
|
||||
{/* Financial-only: cap-table (suppressed for Wandeldarlehen) */}
|
||||
{hasCapTable && <PrintCapTablePage {...p()} />}
|
||||
{/* 25. legal-disclaimer */}
|
||||
|
||||
{/* 35 legal-disclaimer */}
|
||||
<PrintDisclaimerPage {...p()} />
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
import React from 'react'
|
||||
import { COLORS } from './PrintLayout'
|
||||
|
||||
/* ====================================================================== */
|
||||
/* DIAGRAMS */
|
||||
/* ====================================================================== */
|
||||
|
||||
/** A single "node" in a flow / architecture diagram. */
|
||||
export function FlowNode({
|
||||
title, subtitle, items, accent = COLORS.indigo600, icon, kicker, footer,
|
||||
}: {
|
||||
title: string
|
||||
subtitle?: string
|
||||
items?: string[]
|
||||
accent?: string
|
||||
icon?: React.ReactNode
|
||||
kicker?: string
|
||||
footer?: string
|
||||
}) {
|
||||
return (
|
||||
<div style={{
|
||||
border: `1px solid ${COLORS.slate200}`,
|
||||
borderTop: `3px solid ${accent}`,
|
||||
background: '#ffffff',
|
||||
padding: '3mm 4mm',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
WebkitPrintColorAdjust: 'exact',
|
||||
printColorAdjust: 'exact',
|
||||
}}>
|
||||
{kicker && (
|
||||
<div style={{ fontSize: '6.5pt', fontWeight: 700, color: accent, textTransform: 'uppercase', letterSpacing: '0.14em', marginBottom: '1.5mm' }}>{kicker}</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '3mm', marginBottom: '1mm' }}>
|
||||
{icon && <div style={{ flexShrink: 0, color: accent, marginTop: '0.5mm' }}>{icon}</div>}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: '11pt', fontWeight: 700, color: COLORS.slate900, lineHeight: 1.15 }}>{title}</div>
|
||||
{subtitle && <div style={{ fontSize: '8pt', color: accent, fontWeight: 600, marginTop: '0.5mm' }}>{subtitle}</div>}
|
||||
</div>
|
||||
</div>
|
||||
{items && items.length > 0 && (
|
||||
<div style={{ marginTop: '2mm', display: 'flex', flexDirection: 'column' }}>
|
||||
{items.map((it, i) => (
|
||||
<div key={i} style={{
|
||||
fontSize: '7.5pt',
|
||||
color: COLORS.slate700,
|
||||
padding: '1mm 0',
|
||||
borderTop: i > 0 ? `1px solid ${COLORS.slate100}` : 'none',
|
||||
lineHeight: 1.35,
|
||||
}}>{it}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{footer && (
|
||||
<div style={{ marginTop: '2mm', paddingTop: '1.5mm', borderTop: `1px solid ${COLORS.slate100}`, fontSize: '6.5pt', color: COLORS.slate500, fontFamily: 'monospace', lineHeight: 1.4 }}>{footer}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Vertical down arrow (between rows of a flow diagram). */
|
||||
export function VArrow({ color = COLORS.slate400, label }: { color?: string; label?: string }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '1.5mm 0' }}>
|
||||
<svg viewBox="0 0 24 32" style={{ width: '6mm', height: '6mm' }}>
|
||||
<line x1="12" y1="0" x2="12" y2="24" stroke={color} strokeWidth="1.5" />
|
||||
<polyline points="6,22 12,30 18,22" fill="none" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
{label && <div style={{ fontSize: '6.5pt', color: COLORS.slate500, marginTop: '0.5mm', fontWeight: 600 }}>{label}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Horizontal right arrow (between steps of a horizontal flow). */
|
||||
export function HArrow({ color = COLORS.slate400, label }: { color?: string; label?: string }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', minWidth: '6mm', position: 'relative' }}>
|
||||
<svg viewBox="0 0 32 24" style={{ width: '8mm', height: '5mm' }}>
|
||||
<line x1="0" y1="12" x2="24" y2="12" stroke={color} strokeWidth="1.5" />
|
||||
<polyline points="20,6 30,12 20,18" fill="none" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
{label && <div style={{ fontSize: '6.5pt', color: COLORS.slate500, marginTop: '0.5mm', fontWeight: 600, textAlign: 'center', whiteSpace: 'nowrap' }}>{label}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Horizontal step strip with built-in arrows between items. */
|
||||
export function StepStrip({
|
||||
steps, accent = COLORS.indigo600,
|
||||
}: {
|
||||
steps: { n: string; t: string; d: string }[]
|
||||
accent?: string
|
||||
}) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'stretch' }}>
|
||||
{steps.map((s, i) => (
|
||||
<React.Fragment key={i}>
|
||||
<div style={{ flex: 1, padding: '0 2mm', display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: '3mm', marginBottom: '2mm' }}>
|
||||
<span style={{ fontSize: '24pt', fontWeight: 800, color: accent, lineHeight: 1, fontVariantNumeric: 'tabular-nums', letterSpacing: '-0.02em' }}>{s.n}</span>
|
||||
<div style={{ flex: 1, height: '1px', background: COLORS.slate200, alignSelf: 'center' }} />
|
||||
</div>
|
||||
<div style={{ fontSize: '12pt', fontWeight: 700, color: COLORS.slate900, lineHeight: 1.2, marginBottom: '2mm', letterSpacing: '-0.005em' }}>{s.t}</div>
|
||||
<div style={{ fontSize: '8.5pt', color: COLORS.slate700, lineHeight: 1.5, flex: 1 }}>{s.d}</div>
|
||||
</div>
|
||||
{i < steps.length - 1 && (
|
||||
<div style={{ width: '6mm', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<svg viewBox="0 0 24 24" style={{ width: '6mm', height: '6mm', color: COLORS.slate300 }}>
|
||||
<line x1="2" y1="12" x2="18" y2="12" stroke={COLORS.slate300} strokeWidth="1.5" />
|
||||
<polyline points="14,6 20,12 14,18" fill="none" stroke={COLORS.slate300} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 3-tier architecture diagram (product → proxy → inference) */
|
||||
export function ArchitectureDiagram({
|
||||
product, proxy, inference, lang,
|
||||
}: {
|
||||
product: { kicker: string; title: string; subtitle: string; tech: string; services: string[] }[]
|
||||
proxy: { title: string; subtitle: string; features: string[] }
|
||||
inference: { title: string; subtitle: string; tech: string; desc: string }[]
|
||||
lang: 'de' | 'en'
|
||||
}) {
|
||||
const de = lang === 'de'
|
||||
const MONO = "'JetBrains Mono', ui-monospace, monospace"
|
||||
|
||||
/** Layer pill: "01 · APPLICATION LAYER" + sub-label */
|
||||
const LayerChip = ({ n, label, sub, tint }: { n: string; label: string; sub: string; tint: string }) => (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '3mm', marginBottom: '2mm' }}>
|
||||
<span style={{ fontFamily: MONO, fontSize: '7pt', fontWeight: 700, color: tint, textTransform: 'uppercase', letterSpacing: '0.2em', background: '#ffffff', padding: '1mm 3mm', borderRadius: '99pt', border: `1px solid ${tint}66`, WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>{n} · {label}</span>
|
||||
<span style={{ fontSize: '7.5pt', color: COLORS.slate600, fontStyle: 'italic' }}>{sub}</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
/**
|
||||
* ServiceNode — flat, no own border. Used INSIDE a tinted layer card so the
|
||||
* whole layer reads as one panel rather than nested boxes (which is what
|
||||
* was causing the architecture diagram to overflow).
|
||||
*/
|
||||
const ServiceNode = ({ kicker, title, subtitle, items, footer, accent }: { kicker?: string; title: string; subtitle?: string; items: string[]; footer?: string; accent: string }) => (
|
||||
<div style={{ padding: '0 1mm', display: 'flex', flexDirection: 'column' }}>
|
||||
{kicker && <div style={{ fontFamily: MONO, fontSize: '6pt', fontWeight: 700, color: accent, textTransform: 'uppercase', letterSpacing: '0.16em', marginBottom: '0.5mm' }}>{kicker}</div>}
|
||||
<div style={{ fontSize: '10pt', fontWeight: 700, color: COLORS.slate900, lineHeight: 1.15 }}>{title}</div>
|
||||
{subtitle && <div style={{ fontSize: '7.5pt', color: accent, fontWeight: 600, marginTop: '0.5mm' }}>{subtitle}</div>}
|
||||
<div style={{ marginTop: '1.5mm' }}>
|
||||
{items.slice(0, 4).map((it, i) => (
|
||||
<div key={i} style={{ fontSize: '7pt', color: COLORS.slate700, lineHeight: 1.35, padding: '0.4mm 0', borderTop: i > 0 ? `1px solid ${COLORS.slate100}` : 'none' }}>· {it}</div>
|
||||
))}
|
||||
</div>
|
||||
{footer && <div style={{ marginTop: '1.5mm', paddingTop: '1mm', borderTop: `1px solid ${COLORS.slate200}`, fontFamily: MONO, fontSize: '6pt', color: COLORS.slate500, lineHeight: 1.4 }}>{footer}</div>}
|
||||
</div>
|
||||
)
|
||||
|
||||
const productLayerBg = `linear-gradient(135deg, ${COLORS.violet50} 0%, #ffffff 60%, ${COLORS.violet50} 100%)`
|
||||
const proxyLayerBg = `linear-gradient(135deg, ${COLORS.amber50} 0%, #fffaf0 60%, ${COLORS.amber50} 100%)`
|
||||
|
||||
/**
|
||||
* Faked-3D layer wrapper: shadow on the bottom edge (heavier than top), a 1px
|
||||
* top highlight, a 1px darker bottom seam, and a stagger indent on the right
|
||||
* to suggest the stack tilts slightly away from the viewer. This renders
|
||||
* crisply in Chromium's print-to-PDF, unlike `transform: rotateX(...)` which
|
||||
* has print-pipeline quirks.
|
||||
*/
|
||||
const layerWrap = (indentRight: string, shadowTint: string, glowTop: string, seamBottom: string): React.CSSProperties => ({
|
||||
marginRight: indentRight,
|
||||
boxShadow: `inset 0 1px 0 ${glowTop}, inset 0 -1px 0 ${seamBottom}, 0 5mm 7mm -4mm ${shadowTint}`,
|
||||
WebkitPrintColorAdjust: 'exact',
|
||||
printColorAdjust: 'exact',
|
||||
})
|
||||
|
||||
/** Connector that reads as "the upper plane resting on the next" — a soft chevron with a shadow. */
|
||||
const PlaneConnector = ({ color }: { color: string }) => (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '5mm', position: 'relative' }}>
|
||||
<svg viewBox="0 0 40 20" width="14mm" height="5mm" style={{ overflow: 'visible' }}>
|
||||
<polygon points="4,2 36,2 30,16 10,16" fill={color} opacity="0.18" />
|
||||
<polyline points="10,4 20,14 30,4" fill="none" stroke={color} strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5mm' }}>
|
||||
{/* APPLICATION (PRODUCT) LAYER — top plane, smallest indent footprint */}
|
||||
<div style={{
|
||||
background: productLayerBg,
|
||||
border: `1px solid ${COLORS.violet200}`,
|
||||
borderRadius: '4pt',
|
||||
padding: '3mm 4mm',
|
||||
...layerWrap('0mm', 'rgba(59,26,122,0.20)', 'rgba(255,255,255,0.85)', COLORS.violet200),
|
||||
}}>
|
||||
<LayerChip n="01" label={de ? 'Application Layer' : 'Application Layer'} sub={de ? 'Kundenseitige Services' : 'User-facing services'} tint={COLORS.violet600} />
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '5mm' }}>
|
||||
{product.map((p, i) => (
|
||||
<ServiceNode key={i} kicker={p.kicker} title={p.title} subtitle={p.subtitle} items={p.services} accent={COLORS.violet600} footer={p.tech} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PlaneConnector color={COLORS.violet500} />
|
||||
|
||||
{/* GATEWAY LAYER — middle plane, slight indent, slightly heavier shadow */}
|
||||
<div style={{
|
||||
background: proxyLayerBg,
|
||||
border: `1.5px solid ${COLORS.amber600}`,
|
||||
borderRadius: '4pt',
|
||||
padding: '3mm 4mm',
|
||||
...layerWrap('2mm', 'rgba(180,83,9,0.22)', 'rgba(255,255,255,0.85)', '#e9b56a'),
|
||||
}}>
|
||||
<LayerChip n="02" label={de ? 'Gateway Layer' : 'Gateway Layer'} sub={de ? 'Routing & Guardrails' : 'Routing & guardrails'} tint={COLORS.amber700} />
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: '4mm', marginBottom: '2mm' }}>
|
||||
<span style={{ fontSize: '12pt', fontWeight: 800, color: COLORS.slate900, letterSpacing: '-0.005em' }}>{proxy.title}</span>
|
||||
<span style={{ fontFamily: MONO, fontSize: '7pt', color: COLORS.amber700, fontWeight: 600 }}>{proxy.subtitle}</span>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: '3mm', fontSize: '7pt', color: COLORS.slate700 }}>
|
||||
{proxy.features.map((f, i) => (
|
||||
<div key={i} style={{ paddingLeft: '2mm', borderLeft: `1px solid ${COLORS.amber600}`, lineHeight: 1.3 }}>{f}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PlaneConnector color={COLORS.amber600} />
|
||||
|
||||
{/* INFRASTRUCTURE (INFERENCE) LAYER — foundation, deepest indent + shadow */}
|
||||
<div style={{
|
||||
background: productLayerBg,
|
||||
border: `1px solid ${COLORS.violet300}`,
|
||||
borderRadius: '4pt',
|
||||
padding: '3.5mm 4mm',
|
||||
...layerWrap('4mm', 'rgba(59,26,122,0.28)', 'rgba(255,255,255,0.85)', COLORS.violet300),
|
||||
}}>
|
||||
<LayerChip n="03" label={de ? 'Infrastructure Layer' : 'Infrastructure Layer'} sub={de ? 'Compute & Daten · lokal · air-gap-fähig' : 'Compute & data · local · air-gap capable'} tint={COLORS.violet600} />
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '5mm' }}>
|
||||
{inference.map((p, i) => (
|
||||
<ServiceNode key={i} title={p.title} subtitle={p.subtitle} items={[p.desc]} accent={COLORS.violet500} footer={p.tech} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Connected loop diagram for the USP "Compliance ↔ Code always in sync" closing card */
|
||||
export function LoopDiagram({ lang }: { lang: 'de' | 'en' }) {
|
||||
const de = lang === 'de'
|
||||
return (
|
||||
<svg viewBox="0 0 320 80" style={{ width: '100%', height: '24mm', display: 'block' }}>
|
||||
<defs>
|
||||
<marker id="arrR" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="5" markerHeight="5" orient="auto-start-reverse">
|
||||
<path d="M0,0 L10,5 L0,10 z" fill={COLORS.indigo600} />
|
||||
</marker>
|
||||
</defs>
|
||||
{/* Compliance box */}
|
||||
<rect x="6" y="20" width="80" height="40" rx="2" fill={COLORS.indigo50} stroke={COLORS.indigo600} strokeWidth="1" />
|
||||
<text x="46" y="38" fontSize="9" fontWeight="700" textAnchor="middle" fill={COLORS.indigo700}>Compliance</text>
|
||||
<text x="46" y="52" fontSize="7" textAnchor="middle" fill={COLORS.slate600}>{de ? 'Policies · Audits · VVT' : 'Policies · Audits · RoPA'}</text>
|
||||
|
||||
{/* Code box */}
|
||||
<rect x="234" y="20" width="80" height="40" rx="2" fill={COLORS.indigo50} stroke={COLORS.indigo600} strokeWidth="1" />
|
||||
<text x="274" y="38" fontSize="9" fontWeight="700" textAnchor="middle" fill={COLORS.indigo700}>Code</text>
|
||||
<text x="274" y="52" fontSize="7" textAnchor="middle" fill={COLORS.slate600}>{de ? 'Repos · CI/CD · Findings' : 'Repos · CI/CD · Findings'}</text>
|
||||
|
||||
{/* Top arrow: compliance → code */}
|
||||
<path d="M86,30 Q160,5 234,30" fill="none" stroke={COLORS.indigo600} strokeWidth="1.4" markerEnd="url(#arrR)" />
|
||||
<text x="160" y="12" fontSize="7" fontWeight="600" textAnchor="middle" fill={COLORS.indigo700}>{de ? 'Policies → Code (Real-time)' : 'Policies → Code (Real-time)'}</text>
|
||||
|
||||
{/* Bottom arrow: code → compliance */}
|
||||
<path d="M234,50 Q160,75 86,50" fill="none" stroke={COLORS.indigo600} strokeWidth="1.4" markerEnd="url(#arrR)" />
|
||||
<text x="160" y="72" fontSize="7" fontWeight="600" textAnchor="middle" fill={COLORS.indigo700}>{de ? 'Code-Δ → Evidence (Auto)' : 'Code-Δ → Evidence (Auto)'}</text>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** 4-stage horizontal pipeline (e.g. RAG pipeline ingestion → ... → QA) */
|
||||
export function PipelineFlow({
|
||||
stages, accent = COLORS.indigo600,
|
||||
}: {
|
||||
stages: { n: string; t: string; d: string; kpi?: string }[]
|
||||
accent?: string
|
||||
}) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'stretch' }}>
|
||||
{stages.map((s, i) => (
|
||||
<React.Fragment key={i}>
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{
|
||||
border: `1px solid ${COLORS.slate200}`,
|
||||
borderTop: `3px solid ${accent}`,
|
||||
padding: '3mm 4mm',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
WebkitPrintColorAdjust: 'exact',
|
||||
printColorAdjust: 'exact',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', marginBottom: '2mm' }}>
|
||||
<span style={{ fontSize: '18pt', fontWeight: 800, color: accent, lineHeight: 1, fontVariantNumeric: 'tabular-nums' }}>{s.n}</span>
|
||||
{s.kpi && <span style={{ fontSize: '7pt', color: COLORS.emerald700, fontWeight: 700, fontVariantNumeric: 'tabular-nums' }}>{s.kpi}</span>}
|
||||
</div>
|
||||
<div style={{ fontSize: '11pt', fontWeight: 700, color: COLORS.slate900, lineHeight: 1.2, marginBottom: '2mm' }}>{s.t}</div>
|
||||
<div style={{ fontSize: '8pt', color: COLORS.slate700, lineHeight: 1.5, flex: 1 }}>{s.d}</div>
|
||||
</div>
|
||||
</div>
|
||||
{i < stages.length - 1 && (
|
||||
<div style={{ width: '7mm', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<svg viewBox="0 0 24 24" style={{ width: '6mm', height: '6mm' }}>
|
||||
<line x1="2" y1="12" x2="18" y2="12" stroke={accent} strokeWidth="1.5" />
|
||||
<polyline points="14,6 20,12 14,18" fill="none" stroke={accent} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,480 +0,0 @@
|
||||
import { PrintPage, SectionTitle, PrintTable, Badge, COLORS } from './PrintLayout'
|
||||
import { Language, PitchMarket, PitchFunding } from '@/lib/types'
|
||||
|
||||
interface SlideBase { lang: Language; pageNum: number; totalPages: number; versionName: string }
|
||||
|
||||
function fmtEur(n: number) {
|
||||
const abs = Math.abs(n)
|
||||
if (abs >= 1_000_000_000) return `${(n / 1_000_000_000).toLocaleString('de-DE', { maximumFractionDigits: 1 })}B EUR`
|
||||
if (abs >= 1_000_000) return `${(n / 1_000_000).toLocaleString('de-DE', { maximumFractionDigits: 1 })}M EUR`
|
||||
if (abs >= 1_000) return `${(n / 1_000).toLocaleString('de-DE', { maximumFractionDigits: 0 })}k EUR`
|
||||
return `${n.toLocaleString('de-DE')} EUR`
|
||||
}
|
||||
|
||||
export function PrintExecutiveSummaryPage({ market, funding, lang, pageNum, totalPages, versionName }: SlideBase & { market: PitchMarket[]; funding: PitchFunding }) {
|
||||
const de = lang === 'de'
|
||||
const tam = market.find(m => m.market_segment === 'TAM')
|
||||
const sam = market.find(m => m.market_segment === 'SAM')
|
||||
const som = market.find(m => m.market_segment === 'SOM')
|
||||
const moat = [
|
||||
{ k: 'Traceability', v: de ? 'Gesetz → Control → Code' : 'Law → Control → Code' },
|
||||
{ k: 'Continuous Engine', v: de ? 'Echtzeit bei jeder Änderung' : 'Real-time on every change' },
|
||||
{ k: 'Compliance Optimizer', v: de ? 'Maximale KI-Nutzung im Rahmen' : 'Max AI use within regulations' },
|
||||
{ k: 'EU-Trust Stack', v: de ? '100% EU, kein US-SaaS' : '100% EU, no US SaaS' },
|
||||
]
|
||||
const kpis = [
|
||||
{ v: '25k+', l: de ? 'Controls' : 'Controls' },
|
||||
{ v: '380+', l: de ? 'Regularien' : 'Regulations' },
|
||||
{ v: '10', l: de ? 'Branchen' : 'Industries' },
|
||||
{ v: '500K+', l: de ? 'Zeilen Code' : 'Lines of code' },
|
||||
{ v: '80%', l: de ? 'Zeitersparnis' : 'Time saved' },
|
||||
{ v: '10x', l: de ? 'Günstiger als Pentests' : 'Cheaper than pentests' },
|
||||
]
|
||||
return (
|
||||
<PrintPage title={de ? 'Executive Summary' : 'Executive Summary'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
|
||||
<SectionTitle subtitle={de ? 'BreakPilot COMPLAI — DSGVO-konforme KI-Plattform für Code-Security und Compliance' : 'BreakPilot COMPLAI — GDPR-compliant AI platform for code security and compliance'}>
|
||||
Executive Summary
|
||||
</SectionTitle>
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||
<div style={{ background: '#f5f3ff', borderLeft: `3px solid ${COLORS.indigo}`, padding: '10px 14px', borderRadius: '6px', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
<p style={{ fontSize: '10px', color: COLORS.med, margin: 0, lineHeight: 1.55 }}>
|
||||
{de
|
||||
? 'Kontinuierliches Sicherheitsscanning + intelligente Compliance-Automatisierung. Code absichern, Compliance skalierbar durchsetzen, volle Datensouveränität — gestützt auf 25.000+ atomare Prüfaspekte.'
|
||||
: 'Continuous security scanning + intelligent compliance automation. Secure code, enforce compliance at scale, maintain data sovereignty — powered by 25,000+ atomic audit aspects.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p style={{ fontSize: '9px', fontWeight: 700, color: COLORS.indigo, textTransform: 'uppercase', letterSpacing: '0.06em', margin: '0 0 6px' }}>{de ? 'Unser MOAT' : 'Our MOAT'}</p>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '8px' }}>
|
||||
{moat.map(m => (
|
||||
<div key={m.k} style={{ border: `1px solid ${COLORS.border}`, borderRadius: '6px', padding: '8px 10px', borderTop: `2px solid ${COLORS.indigo}` }}>
|
||||
<p style={{ fontSize: '10px', fontWeight: 700, color: COLORS.dark, margin: 0 }}>{m.k}</p>
|
||||
<p style={{ fontSize: '9px', color: COLORS.light, margin: '2px 0 0' }}>{m.v}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(6, 1fr)', gap: '6px' }}>
|
||||
{kpis.map(k => (
|
||||
<div key={k.l} style={{ background: COLORS.indigoLight, padding: '8px', borderRadius: '6px', textAlign: 'center', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
<p style={{ fontSize: '15px', fontWeight: 800, color: COLORS.indigo, margin: 0, lineHeight: 1 }}>{k.v}</p>
|
||||
<p style={{ fontSize: '8px', color: COLORS.med, margin: '3px 0 0', textTransform: 'uppercase', letterSpacing: '0.04em' }}>{k.l}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' }}>
|
||||
<div style={{ border: `1px solid ${COLORS.border}`, borderRadius: '6px', padding: '10px', borderTop: `2px solid #ef4444` }}>
|
||||
<p style={{ fontSize: '10px', fontWeight: 700, color: '#dc2626', margin: '0 0 6px', textTransform: 'uppercase', letterSpacing: '0.04em' }}>{de ? 'Problem' : 'Problem'}</p>
|
||||
<ul style={{ margin: 0, paddingLeft: '14px', fontSize: '9px', color: COLORS.med, lineHeight: 1.55 }}>
|
||||
<li>{de ? 'Ohne KI Wettbewerbsfähigkeit verloren — mit US-KI Datenkontrolle verloren' : 'Without AI: lose competitiveness — with US AI: lose data control'}</li>
|
||||
<li>{de ? 'AI Act, CRA, NIS2 zwingen 30.000+ Firmen in komplexe Compliance' : 'AI Act, CRA, NIS2 force 30,000+ firms into complex compliance'}</li>
|
||||
<li>{de ? 'Hohe Pentest-/Audit-Kosten, jährliche statt kontinuierliche Prüfung' : 'High pentest/audit cost, annual instead of continuous checks'}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div style={{ border: `1px solid ${COLORS.border}`, borderRadius: '6px', padding: '10px', borderTop: `2px solid #10b981` }}>
|
||||
<p style={{ fontSize: '10px', fontWeight: 700, color: '#16a34a', margin: '0 0 6px', textTransform: 'uppercase', letterSpacing: '0.04em' }}>{de ? 'Lösung' : 'Solution'}</p>
|
||||
<ul style={{ margin: 0, paddingLeft: '14px', fontSize: '9px', color: COLORS.med, lineHeight: 1.55 }}>
|
||||
<li>{de ? 'Jede Code-Änderung automatisch geprüft (SAST/DAST/SBOM/Pentest)' : 'Every code change auto-checked (SAST/DAST/SBOM/pentest)'}</li>
|
||||
<li>{de ? 'VVT, TOMs, DSFA, CE-Risikobeurteilung in Echtzeit' : 'RoPA, TOMs, DPIA, CE risk assessment in real time'}</li>
|
||||
<li>{de ? 'EU-Hosting (DE/FR), Audit-Ready zu jedem Zeitpunkt' : 'EU hosting (DE/FR), audit-ready at any time'}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '10px' }}>
|
||||
<div style={{ border: `1px solid ${COLORS.border}`, borderRadius: '6px', padding: '8px' }}>
|
||||
<p style={{ fontSize: '9px', fontWeight: 700, color: '#a855f7', textTransform: 'uppercase', letterSpacing: '0.04em', margin: '0 0 4px' }}>{de ? 'Zielmärkte' : 'Target markets'}</p>
|
||||
<p style={{ fontSize: '9px', color: COLORS.med, margin: 0, lineHeight: 1.5 }}>
|
||||
{de ? 'Maschinen- & Anlagenbau · Automotive · Zulieferer · Produzierende Unternehmen' : 'Machine & plant manufacturing · Automotive · Suppliers · Manufacturing'}
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ border: `1px solid ${COLORS.border}`, borderRadius: '6px', padding: '8px' }}>
|
||||
<p style={{ fontSize: '9px', fontWeight: 700, color: '#f59e0b', textTransform: 'uppercase', letterSpacing: '0.04em', margin: '0 0 4px' }}>{de ? 'Markt' : 'Market'}</p>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '9px', color: COLORS.med }}><span>TAM</span><span>{tam ? fmtEur(tam.value_eur) : '—'}</span></div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '9px', color: COLORS.med }}><span>SAM</span><span>{sam ? fmtEur(sam.value_eur) : '—'}</span></div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '9px', color: COLORS.med }}><span>SOM</span><span>{som ? fmtEur(som.value_eur) : '—'}</span></div>
|
||||
</div>
|
||||
<div style={{ border: `1px solid ${COLORS.border}`, borderRadius: '6px', padding: '8px' }}>
|
||||
<p style={{ fontSize: '9px', fontWeight: 700, color: '#10b981', textTransform: 'uppercase', letterSpacing: '0.04em', margin: '0 0 4px' }}>{de ? 'Kundenersparnis KMU/Jahr' : 'SME savings/year'}</p>
|
||||
<p style={{ fontSize: '14px', fontWeight: 800, color: '#16a34a', margin: 0 }}>~55k EUR</p>
|
||||
<p style={{ fontSize: '8px', color: COLORS.light, margin: '2px 0 0' }}>{de ? 'Pentests, CE, Compliance-Zeit, Audit' : 'Pentests, CE, compliance time, audit'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 'auto', padding: '8px 12px', background: '#f0fdf4', border: '1px solid #bbf7d0', borderRadius: '6px', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
<p style={{ fontSize: '9px', color: '#166534', margin: 0 }}>
|
||||
<strong>{de ? 'The Ask:' : 'The Ask:'}</strong>{' '}
|
||||
{(() => {
|
||||
const amount = Number(funding?.amount_eur) || 0
|
||||
const label = amount >= 1_000_000 ? `${(amount / 1_000_000).toFixed(1)} Mio. EUR` : `${Math.round(amount / 1000)}k EUR`
|
||||
return `${label} ${funding?.instrument || 'Pre-Seed'} · ${funding?.round_name || 'Pre-Seed'}`
|
||||
})()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</PrintPage>
|
||||
)
|
||||
}
|
||||
|
||||
const USP_PILLARS = {
|
||||
de: [
|
||||
{ title: 'RFQ-Prüfung', body: 'Kunden-Anforderungsdokumente automatisch gegen Source-Code geprüft. Abweichungen erkannt, Änderungen vorgeschlagen.', stat: 'Antwortzeit 4,2h (war 12 Tage)' },
|
||||
{ title: 'Prozess-Compliance', body: 'Vom Audit-Finding zum Ticket zur Code-Änderung — End-to-End automatisiert. Rollen, Fristen, Eskalation, Nachweise.', stat: '87% automatisierte Prozessschritte' },
|
||||
{ title: 'Bidirektional', body: 'Compliance-Anforderungen fließen in den Code. Code-Änderungen aktualisieren die Compliance-Doku. Zero Drift.', stat: '0 Drift-Vorfälle seit März 2024' },
|
||||
{ title: 'Kontinuierlich', body: 'Statt jährlicher Stichproben: Prüfung bei jeder Code-Änderung. Findings sofort zu Tickets mit Fix-Vorschlägen.', stat: '~2.400 Validierungen / Tag / Repo' },
|
||||
],
|
||||
en: [
|
||||
{ title: 'RFQ Verification', body: 'Customer requirement docs automatically verified against current source code. Deviations detected, fixes proposed.', stat: 'Response time 4.2h (was 12 days)' },
|
||||
{ title: 'Process Compliance', body: 'From audit finding to ticket to code change — fully automated. Roles, deadlines, escalation, evidence.', stat: '87% process steps automated' },
|
||||
{ title: 'Bidirectional Sync', body: 'Compliance requirements flow into code. Code changes update compliance docs. Zero drift between worlds.', stat: '0 drift incidents since Mar-2024' },
|
||||
{ title: 'Continuous, Not Yearly', body: 'Validation on every code change instead of annual checks. Findings as tickets with concrete fix proposals.', stat: '~2,400 validations / day / repo' },
|
||||
],
|
||||
}
|
||||
|
||||
const USP_HOOD = {
|
||||
de: [
|
||||
{ title: 'End-to-End Traceability', body: 'Gesetz → Obligation → Control deterministisch mit Systemzustand und Code verknüpft. Revisionssicherer Evidence-Layer.' },
|
||||
{ title: 'Continuous Compliance Engine', body: 'Validierung bei jeder Änderung (Code/IaC/Prozesse) mit auditierbaren Nachweisen in Echtzeit. Rule-Packs pro Framework.' },
|
||||
{ title: 'Compliance Optimizer', body: 'Maximal zulässige Ausgestaltung jedes KI-Use-Cases. Constraint-Optimierung statt nur erlaubt/verboten — spart 20–200k EUR Anwaltskosten.' },
|
||||
{ title: 'EU-Trust & Governance Stack', body: 'DSGVO · NIS-2 · DORA · EU AI Act · ISO 27001 · BSI C5 · EU-souveränes Hosting. Eine Plattform, ein Audit.' },
|
||||
],
|
||||
en: [
|
||||
{ title: 'End-to-End Traceability', body: 'Law → Obligation → Control deterministically linked to system state and code. Audit-proof evidence layer.' },
|
||||
{ title: 'Continuous Compliance Engine', body: 'Validation on every change (code/IaC/process) with auditable evidence in real time. Rule packs per framework.' },
|
||||
{ title: 'Compliance Optimizer', body: 'Max permissible configuration of every AI use case. Constraint optimization beyond allowed/forbidden — replaces EUR 20–200k legal fees.' },
|
||||
{ title: 'EU Trust & Governance Stack', body: 'GDPR · NIS-2 · DORA · EU AI Act · ISO 27001 · BSI C5 · EU-sovereign hosting. One platform, one audit.' },
|
||||
],
|
||||
}
|
||||
|
||||
export function PrintUSPPage({ lang, pageNum, totalPages, versionName }: SlideBase) {
|
||||
const de = lang === 'de'
|
||||
const pillars = de ? USP_PILLARS.de : USP_PILLARS.en
|
||||
const hood = de ? USP_HOOD.de : USP_HOOD.en
|
||||
return (
|
||||
<PrintPage title="USP" pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
|
||||
<SectionTitle subtitle={de ? 'Vier Säulen Compliance + Code, vier technische Differenzierer' : 'Four pillars compliance + code, four technical differentiators'}>
|
||||
{de ? 'Unsere USPs' : 'Our USPs'}
|
||||
</SectionTitle>
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||
<div>
|
||||
<p style={{ fontSize: '9px', fontWeight: 700, color: COLORS.indigo, textTransform: 'uppercase', letterSpacing: '0.06em', margin: '0 0 6px' }}>{de ? 'Vier Säulen' : 'Four Pillars'}</p>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '8px' }}>
|
||||
{pillars.map(p => (
|
||||
<div key={p.title} style={{ border: `1px solid ${COLORS.border}`, borderRadius: '6px', padding: '10px', borderTop: `2px solid ${COLORS.indigo}` }}>
|
||||
<p style={{ fontSize: '11px', fontWeight: 700, color: COLORS.dark, margin: '0 0 4px' }}>{p.title}</p>
|
||||
<p style={{ fontSize: '9px', color: COLORS.med, margin: '0 0 6px', lineHeight: 1.5 }}>{p.body}</p>
|
||||
<p style={{ fontSize: '8px', fontWeight: 700, color: COLORS.indigo, margin: 0, fontFamily: 'ui-monospace, monospace' }}>{p.stat}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p style={{ fontSize: '9px', fontWeight: 700, color: COLORS.indigo, textTransform: 'uppercase', letterSpacing: '0.06em', margin: '0 0 6px' }}>{de ? 'Under the Hood' : 'Under the Hood'}</p>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '8px' }}>
|
||||
{hood.map(h => (
|
||||
<div key={h.title} style={{ border: `1px solid ${COLORS.border}`, borderRadius: '6px', padding: '10px' }}>
|
||||
<p style={{ fontSize: '11px', fontWeight: 700, color: COLORS.dark, margin: '0 0 4px' }}>{h.title}</p>
|
||||
<p style={{ fontSize: '9px', color: COLORS.med, margin: 0, lineHeight: 1.5 }}>{h.body}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginTop: 'auto', padding: '10px 14px', background: '#f5f3ff', borderRadius: '6px', borderLeft: `3px solid ${COLORS.indigo}`, WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
<p style={{ fontSize: '10px', color: COLORS.med, margin: 0, fontStyle: 'italic', lineHeight: 1.5 }}>
|
||||
{de
|
||||
? '„Compliance ↔ Code · immer in Sync. Eine Plattform, eine geschlossene Schleife. Auditoren, Entwickler und Sales fragen denselben Graphen ab."'
|
||||
: '"Compliance ↔ Code · always in sync. One platform, one closed loop. Auditors, engineers and sales all query the same graph."'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</PrintPage>
|
||||
)
|
||||
}
|
||||
|
||||
const REG_KEY = [
|
||||
{ id: 'GDPR', label: 'DSGVO', color: '#6366f1' },
|
||||
{ id: 'AI_ACT', label: 'AI Act', color: '#a855f7' },
|
||||
{ id: 'NIS2', label: 'NIS2', color: '#ef4444' },
|
||||
{ id: 'CRA', label: 'CRA', color: '#f97316' },
|
||||
{ id: 'MACHINERY_REG', label: 'Masch.-VO', color: '#22c55e' },
|
||||
{ id: 'DATA_ACT', label: 'Data Act', color: '#06b6d4' },
|
||||
{ id: 'BATTERIE_VO', label: 'Batt.-VO', color: '#f59e0b' },
|
||||
]
|
||||
const REG_INDUSTRIES = [
|
||||
{ de: 'Automobilindustrie', en: 'Automotive', regs: ['GDPR', 'AI_ACT', 'NIS2', 'CRA', 'MACHINERY_REG', 'DATA_ACT', 'BATTERIE_VO'], totalDocs: 263 },
|
||||
{ de: 'Maschinen- & Anlagenbau', en: 'Machinery & Plant Eng.', regs: ['GDPR', 'AI_ACT', 'NIS2', 'CRA', 'MACHINERY_REG', 'DATA_ACT'], totalDocs: 266 },
|
||||
{ de: 'Elektro- & Digitalindustrie', en: 'Electrical & Digital', regs: ['GDPR', 'AI_ACT', 'NIS2', 'CRA', 'MACHINERY_REG', 'DATA_ACT', 'BATTERIE_VO'], totalDocs: 281 },
|
||||
{ de: 'Chemie- & Prozessindustrie', en: 'Chemicals & Process', regs: ['GDPR', 'AI_ACT', 'NIS2', 'CRA', 'DATA_ACT'], totalDocs: 250 },
|
||||
{ de: 'Metallindustrie', en: 'Metal Industry', regs: ['GDPR', 'AI_ACT', 'NIS2', 'CRA', 'MACHINERY_REG', 'DATA_ACT'], totalDocs: 246 },
|
||||
{ de: 'Energie & Versorgung', en: 'Energy & Utilities', regs: ['GDPR', 'AI_ACT', 'NIS2', 'CRA', 'DATA_ACT', 'BATTERIE_VO'], totalDocs: 256 },
|
||||
{ de: 'Transport & Logistik', en: 'Transport & Logistics', regs: ['GDPR', 'AI_ACT', 'NIS2', 'CRA', 'DATA_ACT'], totalDocs: 256 },
|
||||
{ de: 'Handel', en: 'Retail & Commerce', regs: ['GDPR', 'AI_ACT', 'NIS2', 'CRA', 'DATA_ACT'], totalDocs: 271 },
|
||||
{ de: 'Konsumgüter & Lebensmittel', en: 'Consumer Goods & Food', regs: ['GDPR', 'AI_ACT', 'NIS2', 'CRA', 'DATA_ACT', 'BATTERIE_VO'], totalDocs: 265 },
|
||||
{ de: 'Bauwirtschaft', en: 'Construction', regs: ['GDPR', 'AI_ACT', 'NIS2', 'CRA', 'MACHINERY_REG', 'DATA_ACT'], totalDocs: 245 },
|
||||
]
|
||||
|
||||
export function PrintRegulatoryLandscapePage({ lang, pageNum, totalPages, versionName }: SlideBase) {
|
||||
const de = lang === 'de'
|
||||
const kpis = [
|
||||
{ v: '380+', l: de ? 'Gesetze im RAG' : 'Laws in RAG' },
|
||||
{ v: '244', l: de ? 'Horizontal' : 'Horizontal' },
|
||||
{ v: '65', l: de ? 'Branchen-spezifisch' : 'Industry-specific' },
|
||||
{ v: '10', l: de ? 'Branchen' : 'Industries' },
|
||||
]
|
||||
return (
|
||||
<PrintPage title={de ? 'Regulatorische Landschaft' : 'Regulatory Landscape'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
|
||||
<SectionTitle subtitle={de ? '380+ Regularien, 10 Branchen — was wo gilt' : '380+ regulations, 10 industries — what applies where'}>
|
||||
{de ? 'Regulatorische Landschaft' : 'Regulatory Landscape'}
|
||||
</SectionTitle>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '8px', marginBottom: '10px' }}>
|
||||
{kpis.map(k => (
|
||||
<div key={k.l} style={{ background: COLORS.indigoLight, padding: '8px', borderRadius: '6px', textAlign: 'center', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
<p style={{ fontSize: '16px', fontWeight: 800, color: COLORS.indigo, margin: 0 }}>{k.v}</p>
|
||||
<p style={{ fontSize: '8px', color: COLORS.med, margin: '2px 0 0', textTransform: 'uppercase', letterSpacing: '0.04em' }}>{k.l}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '9px' }}>
|
||||
<thead>
|
||||
<tr style={{ background: COLORS.indigoLight, WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
<th style={{ padding: '5px 7px', textAlign: 'left', fontSize: '8px', textTransform: 'uppercase', letterSpacing: '0.04em', color: COLORS.dark }}>{de ? 'Branche' : 'Industry'}</th>
|
||||
{REG_KEY.map(r => (
|
||||
<th key={r.id} style={{ padding: '5px 4px', textAlign: 'center', fontSize: '8px', textTransform: 'uppercase', color: r.color, WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>{r.label}</th>
|
||||
))}
|
||||
<th style={{ padding: '5px 7px', textAlign: 'right', fontSize: '8px', textTransform: 'uppercase', letterSpacing: '0.04em', color: COLORS.indigo }}>{de ? 'Gesetze' : 'Laws'}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{REG_INDUSTRIES.map((ind, i) => (
|
||||
<tr key={ind.de} style={{ background: i % 2 === 0 ? '#ffffff' : '#fafafa' }}>
|
||||
<td style={{ padding: '5px 7px', color: COLORS.dark, fontWeight: 500, borderBottom: `1px solid ${COLORS.border}` }}>{de ? ind.de : ind.en}</td>
|
||||
{REG_KEY.map(r => (
|
||||
<td key={r.id} style={{ padding: '5px 4px', textAlign: 'center', borderBottom: `1px solid ${COLORS.border}` }}>
|
||||
{ind.regs.includes(r.id)
|
||||
? <span style={{ display: 'inline-block', width: '8px', height: '8px', borderRadius: '99px', background: r.color, WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }} />
|
||||
: <span style={{ color: COLORS.border }}>·</span>}
|
||||
</td>
|
||||
))}
|
||||
<td style={{ padding: '5px 7px', textAlign: 'right', fontWeight: 700, color: COLORS.dark, borderBottom: `1px solid ${COLORS.border}` }}>{ind.totalDocs}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<p style={{ fontSize: '8px', color: COLORS.light, margin: '8px 0 0', fontStyle: 'italic' }}>
|
||||
{de
|
||||
? '244 Dokumente gelten horizontal für alle Branchen (DSGVO, BDSG, AI Act, NIS2, CRA, BetrVG, HGB, ...). Sektorspezifische Regulierungen kommen hinzu.'
|
||||
: '244 documents apply horizontally to all industries (GDPR, BDSG, AI Act, NIS2, CRA, ...). Sector-specific regulations are added on top.'}
|
||||
</p>
|
||||
</PrintPage>
|
||||
)
|
||||
}
|
||||
|
||||
const HIW_STEPS_DE = [
|
||||
{ n: '01', t: 'Cloud-Vertrag abschließen', d: 'BSI-zertifizierte Cloud in Deutschland. Fixe oder flexible Kosten.' },
|
||||
{ n: '02', t: 'Code-Repos verbinden', d: 'Git-Repos, CI/CD Pipelines und Firmware-Projekte anbinden. Die KI scannt automatisch auf Schwachstellen und Compliance-Lücken bei jeder Änderung.' },
|
||||
{ n: '03', t: 'Compliance & Security automatisieren', d: 'Kontinuierliche Code-Analyse, Pentesting und Risikoanalysen. VVT, TOMs, DSFA und CE-Dokumentation werden automatisch erstellt und aktualisiert.' },
|
||||
{ n: '04', t: 'Audit vorbereiten', d: 'Alle Nachweise, Dokumente und Risikobeurteilungen auf Knopfdruck. Abweichungen nach dem Audit automatisch nachverfolgen mit Stichtagen und Eskalation.' },
|
||||
]
|
||||
const HIW_STEPS_EN = [
|
||||
{ n: '01', t: 'Sign Cloud Contract', d: 'BSI-certified cloud in Germany. Fixed or flexible costs.' },
|
||||
{ n: '02', t: 'Connect Code Repos', d: 'Connect Git repos, CI/CD pipelines and firmware projects. The AI scans automatically for vulnerabilities and compliance gaps on every change.' },
|
||||
{ n: '03', t: 'Automate Compliance & Security', d: 'Continuous code analysis, pentesting and risk assessments. RoPA, TOMs, DPIA and CE documentation are automatically created and updated.' },
|
||||
{ n: '04', t: 'Prepare for Audit', d: 'All evidence, documents and risk assessments at the push of a button. Post-audit deviations automatically tracked with deadlines and escalation.' },
|
||||
]
|
||||
|
||||
export function PrintHowItWorksPage({ lang, pageNum, totalPages, versionName }: SlideBase) {
|
||||
const de = lang === 'de'
|
||||
const steps = de ? HIW_STEPS_DE : HIW_STEPS_EN
|
||||
return (
|
||||
<PrintPage title={de ? 'So funktioniert\'s' : 'How It Works'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
|
||||
<SectionTitle subtitle={de ? 'In 4 Schritten zur kontinuierlichen Compliance' : 'Continuous compliance in 4 steps'}>
|
||||
{de ? 'So funktioniert\'s' : 'How It Works'}
|
||||
</SectionTitle>
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', gap: '14px' }}>
|
||||
{steps.map((s, idx) => (
|
||||
<div key={s.n} style={{ display: 'flex', gap: '14px', alignItems: 'flex-start', borderLeft: `3px solid ${COLORS.indigo}`, paddingLeft: '14px' }}>
|
||||
<div style={{ width: '44px', height: '44px', borderRadius: '8px', background: COLORS.indigoLight, color: COLORS.indigo, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '15px', fontWeight: 800, flexShrink: 0, WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
{s.n}
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<p style={{ fontSize: '15px', fontWeight: 700, color: COLORS.dark, margin: '0 0 4px' }}>{s.t}</p>
|
||||
<p style={{ fontSize: '11px', color: COLORS.med, margin: 0, lineHeight: 1.55 }}>{s.d}</p>
|
||||
</div>
|
||||
{idx < steps.length - 1 && <span style={{ position: 'absolute' }} />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</PrintPage>
|
||||
)
|
||||
}
|
||||
|
||||
const BM_TIERS_DE = [
|
||||
{ name: 'Starter', target: 'Startups & Kleinstunternehmen', emp: '< 10', price: '3.600 EUR/Jahr', features: ['Code Security (SAST/DAST)', 'Compliance-Dokumente', 'Consent Management', '1 Anwendung'], highlight: false },
|
||||
{ name: 'Professional', target: 'KMU & Mittelstand', emp: '10 – 250', price: '15.000 – 40.000 EUR/Jahr', features: ['Alle Module inkl. CE-Bewertung', 'Audit Manager End-to-End', 'AI Act Compliance (UCCA)', 'Unbegrenzte Anwendungen'], highlight: true },
|
||||
{ name: 'Enterprise', target: 'Konzerne & OEMs', emp: '250+', price: 'ab 50.000 EUR/Jahr', features: ['Dedizierte Instanz', 'Custom Integrationen (SAP, MES)', 'SLA & Priority Support', 'Tender Matching & RFQ-Prüfung'], highlight: false },
|
||||
]
|
||||
const BM_TIERS_EN = [
|
||||
{ name: 'Starter', target: 'Startups & Micro', emp: '< 10', price: '3,600 EUR/yr', features: ['Code Security (SAST/DAST)', 'Compliance documents', 'Consent management', '1 application'], highlight: false },
|
||||
{ name: 'Professional', target: 'SME & Mid-Market', emp: '10 – 250', price: '15,000 – 40,000 EUR/yr', features: ['All modules incl. CE assessment', 'Audit Manager end-to-end', 'AI Act Compliance (UCCA)', 'Unlimited applications'], highlight: true },
|
||||
{ name: 'Enterprise', target: 'Enterprises & OEMs', emp: '250+', price: 'from 50,000 EUR/yr', features: ['Dedicated instance', 'Custom integrations (SAP, MES)', 'SLA & priority support', 'Tender matching & RFQ verification'], highlight: false },
|
||||
]
|
||||
|
||||
export function PrintBusinessModelPage({ lang, pageNum, totalPages, versionName }: SlideBase) {
|
||||
const de = lang === 'de'
|
||||
const tiers = de ? BM_TIERS_DE : BM_TIERS_EN
|
||||
return (
|
||||
<PrintPage title={de ? 'Geschäftsmodell' : 'Business Model'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
|
||||
<SectionTitle subtitle={de ? 'Drei Tiers — modular, jährliche Subscription, kein Setup-Fee' : 'Three tiers — modular, annual subscription, no setup fee'}>
|
||||
{de ? 'Geschäftsmodell' : 'Business Model'}
|
||||
</SectionTitle>
|
||||
<div style={{ flex: 1, display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '12px' }}>
|
||||
{tiers.map(t => (
|
||||
<div key={t.name} style={{
|
||||
border: `1px solid ${t.highlight ? COLORS.indigo : COLORS.border}`,
|
||||
borderRadius: '10px',
|
||||
padding: '16px',
|
||||
background: t.highlight ? '#f5f3ff' : '#fff',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
WebkitPrintColorAdjust: 'exact',
|
||||
printColorAdjust: 'exact',
|
||||
borderTop: `3px solid ${t.highlight ? COLORS.indigo : '#cbd5e1'}`,
|
||||
}}>
|
||||
<p style={{ fontSize: '18px', fontWeight: 800, color: COLORS.dark, margin: '0 0 2px' }}>{t.name}</p>
|
||||
<p style={{ fontSize: '10px', color: COLORS.med, margin: '0 0 2px' }}>{t.target}</p>
|
||||
<p style={{ fontSize: '9px', color: COLORS.light, margin: '0 0 14px' }}>{t.emp} {de ? 'Mitarbeiter' : 'employees'}</p>
|
||||
<p style={{ fontSize: '20px', fontWeight: 800, color: t.highlight ? COLORS.indigo : COLORS.dark, margin: '0 0 14px' }}>{t.price}</p>
|
||||
<ul style={{ margin: 0, paddingLeft: '0', listStyle: 'none', flex: 1 }}>
|
||||
{t.features.map(f => (
|
||||
<li key={f} style={{ fontSize: '10px', color: COLORS.med, padding: '4px 0 4px 14px', position: 'relative', lineHeight: 1.4 }}>
|
||||
<span style={{ position: 'absolute', left: 0, top: '8px', width: '5px', height: '5px', borderRadius: '99px', background: COLORS.indigo, WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }} />
|
||||
{f}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</PrintPage>
|
||||
)
|
||||
}
|
||||
|
||||
const COMP_COMPETITORS = [
|
||||
{ name: 'Vanta', flag: 'US', founded: 2018, emp: '1.695', revenue: '$220M ARR', customers: '12.000', pricing: '$10K–80K/yr', ai: 'full' as const },
|
||||
{ name: 'Drata', flag: 'US', founded: 2020, emp: '732', revenue: '$100M ARR', customers: '8.000', pricing: '$10K–100K/yr', ai: 'full' as const },
|
||||
{ name: 'Sprinto', flag: 'IN', founded: 2020, emp: '316', revenue: '$38M ARR', customers: '3.000', pricing: '$6K–25K/yr', ai: 'full' as const },
|
||||
{ name: 'DataGuard', flag: 'DE', founded: 2017, emp: '250', revenue: '~€52M', customers: '4.000', pricing: '€6K–24K+/yr', ai: 'partial' as const },
|
||||
{ name: 'Proliance', flag: 'DE', founded: 2017, emp: '65', revenue: '~€3.9M', customers: '2.000', pricing: '€1.5K–5.7K/yr', ai: 'none' as const },
|
||||
{ name: 'heyData', flag: 'DE', founded: 2020, emp: '58', revenue: '~€15M', customers: '2.000', pricing: '€1K–3.8K/yr', ai: 'partial' as const },
|
||||
]
|
||||
|
||||
const COMP_USP_ROWS_DE = ['Code-Security + DevSecOps (6 Tools, SAST/DAST/SBOM/Container/Secrets/IaC)', 'LLM-Auto-Fix für gefundene Schwachstellen', 'Firmware & Embedded-Security', 'PII-Redaction LLM Gateway', 'RAG mit 25.000+ Sicherheitskontrollen', 'AI Act und CRA Compliance End-to-End', 'CE-Software-Risikobeurteilung nach Maschinen-VO', 'Whistleblower-Portal (HinSchG)', 'Maschinenbau-Branchenfokus', 'Self-Hosted / On-Premise möglich']
|
||||
const COMP_USP_ROWS_EN = ['Code security + DevSecOps (6 tools, SAST/DAST/SBOM/container/secrets/IaC)', 'LLM auto-fix for detected vulnerabilities', 'Firmware & embedded security', 'PII redaction LLM gateway', 'RAG with 25,000+ security controls', 'AI Act and CRA compliance end-to-end', 'CE software risk assessment per Machinery Regulation', 'Whistleblower portal (HinSchG)', 'Manufacturing industry focus', 'Self-hosted / on-premise possible']
|
||||
|
||||
export function PrintCompetitionPage({ lang, pageNum, totalPages, versionName }: SlideBase) {
|
||||
const de = lang === 'de'
|
||||
const aiLabel = (a: 'full' | 'partial' | 'none') => a === 'full' ? (de ? 'Voll' : 'Full') : a === 'partial' ? (de ? 'Teil' : 'Partial') : (de ? 'Keine' : 'None')
|
||||
const aiColor = (a: 'full' | 'partial' | 'none') => a === 'full' ? '#16a34a' : a === 'partial' ? '#d97706' : '#94a3b8'
|
||||
return (
|
||||
<PrintPage title={de ? 'Wettbewerb' : 'Competition'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
|
||||
<SectionTitle subtitle={de ? 'Niemand verbindet Code-Security mit Compliance-Automatisierung — wir schon' : 'Nobody combines code security with compliance automation — we do'}>
|
||||
{de ? 'Wettbewerb' : 'Competition'}
|
||||
</SectionTitle>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1.4fr 1fr', gap: '12px', flex: 1 }}>
|
||||
<div>
|
||||
<p style={{ fontSize: '9px', fontWeight: 700, color: COLORS.indigo, textTransform: 'uppercase', letterSpacing: '0.06em', margin: '0 0 6px' }}>{de ? 'Wettbewerber-Übersicht' : 'Competitor Overview'}</p>
|
||||
<PrintTable
|
||||
headers={[de ? 'Name' : 'Name', de ? 'Gegr.' : 'Est.', 'MA', de ? 'Umsatz' : 'Revenue', de ? 'Kunden' : 'Customers', 'Pricing', 'KI']}
|
||||
rows={COMP_COMPETITORS.map(c => [
|
||||
<span key="n" style={{ fontWeight: 700 }}>{c.flag} {c.name}</span>,
|
||||
c.founded.toString(),
|
||||
c.emp,
|
||||
c.revenue,
|
||||
c.customers,
|
||||
c.pricing,
|
||||
<span key="ai" style={{ color: aiColor(c.ai), fontWeight: 700 }}>{aiLabel(c.ai)}</span>,
|
||||
])}
|
||||
colWidths={['18%', '8%', '10%', '15%', '12%', '20%', '10%']}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p style={{ fontSize: '9px', fontWeight: 700, color: COLORS.indigo, textTransform: 'uppercase', letterSpacing: '0.06em', margin: '0 0 6px' }}>{de ? 'Was nur BreakPilot hat' : 'BreakPilot-only features'}</p>
|
||||
<ul style={{ margin: 0, paddingLeft: 0, listStyle: 'none' }}>
|
||||
{(de ? COMP_USP_ROWS_DE : COMP_USP_ROWS_EN).map(r => (
|
||||
<li key={r} style={{ fontSize: '9px', color: COLORS.med, padding: '4px 0 4px 16px', position: 'relative', lineHeight: 1.45, borderBottom: `1px solid ${COLORS.border}` }}>
|
||||
<span style={{ position: 'absolute', left: 0, top: '7px', width: '8px', height: '8px', borderRadius: '99px', background: COLORS.indigo, WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }} />
|
||||
{r}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<p style={{ fontSize: '8px', color: COLORS.light, margin: '8px 0 0', fontStyle: 'italic' }}>
|
||||
{de
|
||||
? 'Weitere DACH-Anbieter: Secjur, Usercentrics, Caralegal, 2B Advice, OneTrust. Keiner kombiniert DSGVO + Code-Security + Self-Hosted KI.'
|
||||
: 'Other DACH players: Secjur, Usercentrics, Caralegal, 2B Advice, OneTrust. None combines GDPR + code security + self-hosted AI.'}
|
||||
</p>
|
||||
</PrintPage>
|
||||
)
|
||||
}
|
||||
|
||||
const SAVINGS_DE = [
|
||||
{ name: 'KMU (25 MA)', bp: '15.000 EUR/Jahr', without: '86.000', with: '31.000', save: '55.000', roi: '3,7x' },
|
||||
{ name: 'Mittelstand (100 MA)', bp: '30.000 EUR/Jahr', without: '291.000', with: '98.000', save: '193.000', roi: '6,4x' },
|
||||
{ name: 'Konzern (500+ MA)', bp: '50.000 EUR/Jahr', without: '1.190.000', with: '410.000', save: '780.000', roi: '15,6x' },
|
||||
]
|
||||
const SAVINGS_EN = [
|
||||
{ name: 'SME (25 emp.)', bp: 'EUR 15,000/yr', without: '86,000', with: '31,000', save: '55,000', roi: '3.7x' },
|
||||
{ name: 'Mid-size (100 emp.)', bp: 'EUR 30,000/yr', without: '291,000', with: '98,000', save: '193,000', roi: '6.4x' },
|
||||
{ name: 'Enterprise (500+ emp.)', bp: 'EUR 50,000/yr', without: '1,190,000', with: '410,000', save: '780,000', roi: '15.6x' },
|
||||
]
|
||||
const SAVINGS_LINES_DE = ['Pentests (Anwendungen)', 'CE-SW-Risikobeurteilung', 'Compliance-Dokumentation', 'Produktivere Compliance-Arbeitszeit', 'Audit-Vorbereitung', 'Externe Berater / FTE / Strafvermeidung']
|
||||
const SAVINGS_LINES_EN = ['Pentests (applications)', 'CE SW risk assessment', 'Compliance documentation', 'More productive compliance time', 'Audit preparation', 'External consultants / FTE / penalty avoidance']
|
||||
|
||||
export function PrintCustomerSavingsPage({ lang, pageNum, totalPages, versionName }: SlideBase) {
|
||||
const de = lang === 'de'
|
||||
const rows = (de ? SAVINGS_DE : SAVINGS_EN).map(r => [
|
||||
<strong key="n">{r.name}</strong>,
|
||||
r.bp,
|
||||
<span key="w" style={{ color: '#dc2626' }}>{r.without} €</span>,
|
||||
<span key="m" style={{ color: COLORS.med }}>{r.with} €</span>,
|
||||
<span key="s" style={{ color: '#16a34a', fontWeight: 700 }}>{r.save} €</span>,
|
||||
<span key="r" style={{ color: COLORS.indigo, fontWeight: 700 }}>{r.roi}</span>,
|
||||
])
|
||||
return (
|
||||
<PrintPage title={de ? 'Kundenersparnis' : 'Customer Savings'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
|
||||
<SectionTitle subtitle={de ? 'Pentests, CE-Beurteilungen, FTE-Aufwand — Kosten skalieren linear, unsere Plattform nicht' : 'Pentests, CE assessments, FTE effort — costs scale linearly, our platform does not'}>
|
||||
{de ? 'Kundenersparnis im Detail' : 'Customer Savings in Detail'}
|
||||
</SectionTitle>
|
||||
<div style={{ marginBottom: '10px' }}>
|
||||
<PrintTable
|
||||
headers={[de ? 'Kundenprofil' : 'Customer profile', de ? 'BreakPilot' : 'BreakPilot', de ? 'Ohne' : 'Without', de ? 'Mit' : 'With', de ? 'Ersparnis/Jahr' : 'Savings/year', 'ROI']}
|
||||
rows={rows}
|
||||
colWidths={['25%', '18%', '15%', '12%', '20%', '10%']}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px', flex: 1 }}>
|
||||
<div style={{ border: `1px solid ${COLORS.border}`, borderRadius: '6px', padding: '10px' }}>
|
||||
<p style={{ fontSize: '9px', fontWeight: 700, color: COLORS.indigo, textTransform: 'uppercase', letterSpacing: '0.04em', margin: '0 0 6px' }}>{de ? 'Wo gespart wird' : 'Where savings come from'}</p>
|
||||
<ul style={{ margin: 0, paddingLeft: '14px', fontSize: '10px', color: COLORS.med, lineHeight: 1.55 }}>
|
||||
{(de ? SAVINGS_LINES_DE : SAVINGS_LINES_EN).map(l => <li key={l}>{l}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
<div style={{ background: '#f0fdf4', border: '1px solid #bbf7d0', borderRadius: '6px', padding: '10px', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
<p style={{ fontSize: '9px', fontWeight: 700, color: '#166534', textTransform: 'uppercase', letterSpacing: '0.04em', margin: '0 0 6px' }}>{de ? 'Versteckter Hebel' : 'Hidden lever'}</p>
|
||||
<p style={{ fontSize: '10px', color: '#166534', margin: 0, lineHeight: 1.55, fontStyle: 'italic' }}>
|
||||
{de
|
||||
? '„Der größte versteckte Kostentreiber ist Entwickler-Produktivität: ohne automatisierte Security-Tools verbringen Entwickler 19% ihrer Arbeitszeit mit Sicherheitsaufgaben statt mit Features." — IDC'
|
||||
: '"The largest hidden cost driver is developer productivity: without automated security tools, developers spend 19% of their time on security tasks instead of features." — IDC'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginTop: '10px', display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
|
||||
{(de ? ['Pentests', 'CE-Risiko', 'Compliance-Zeit', 'Audit-Vorb.', 'Strafvermeidung'] : ['Pentests', 'CE risk', 'Compliance time', 'Audit prep', 'Penalty avoidance']).map(b => <Badge key={b}>{b}</Badge>)}
|
||||
</div>
|
||||
</PrintPage>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { PrintPage, SectionTitle, PrintTable, Badge, COLORS } from './PrintLayout'
|
||||
import { Language, FMResult, FMAssumption } from '@/lib/types'
|
||||
import { Page, COLORS, Callout, DataTable } from './PrintLayout'
|
||||
import { BarChart, LineChart, DonutChart } from './PrintCharts'
|
||||
import { computeAnnualKPIs } from '@/lib/finanzplan/annual-kpis'
|
||||
|
||||
interface SlideBase { lang: Language; pageNum: number; totalPages: number; versionName: string }
|
||||
|
||||
@@ -34,121 +36,210 @@ export function aggregateAnnualRows(results: FMResult[]): AnnualPLRow[] {
|
||||
const gross = revenue - cogs
|
||||
const ebitda = gross - personnel - marketing - infra
|
||||
return {
|
||||
year,
|
||||
revenue_eur: revenue,
|
||||
cogs_eur: cogs,
|
||||
gross_profit_eur: gross,
|
||||
personnel_eur: personnel,
|
||||
marketing_eur: marketing,
|
||||
infra_eur: infra,
|
||||
ebitda_eur: ebitda,
|
||||
total_customers: last?.total_customers ?? 0,
|
||||
employees_count: last?.employees_count ?? 0,
|
||||
year, revenue_eur: revenue, cogs_eur: cogs, gross_profit_eur: gross,
|
||||
personnel_eur: personnel, marketing_eur: marketing, infra_eur: infra, ebitda_eur: ebitda,
|
||||
total_customers: last?.total_customers ?? 0, employees_count: last?.employees_count ?? 0,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function fmtEur(n: number) {
|
||||
function fmtEur(n: number, dense = false): string {
|
||||
const abs = Math.abs(n)
|
||||
if (abs >= 1_000_000) return `${(n / 1_000_000).toLocaleString('de-DE', { maximumFractionDigits: 1 })}M`
|
||||
if (abs >= 1_000) return `${(n / 1_000).toLocaleString('de-DE', { maximumFractionDigits: 0 })}k`
|
||||
return n.toLocaleString('de-DE', { maximumFractionDigits: 0 })
|
||||
const sign = n < 0 ? '−' : ''
|
||||
if (abs >= 1e6) return `${sign}${(abs / 1e6).toLocaleString('de-DE', { maximumFractionDigits: dense ? 1 : 2 })}M`
|
||||
if (abs >= 1e3) return `${sign}${(abs / 1e3).toLocaleString('de-DE', { maximumFractionDigits: 0 })}k`
|
||||
return `${sign}${abs.toLocaleString('de-DE', { maximumFractionDigits: 0 })}`
|
||||
}
|
||||
|
||||
function colorEur(n: number) { return n >= 0 ? '#16a34a' : '#dc2626' }
|
||||
/* ===== FINANZPLAN, PAGE 1: P&L 2026-2030 ===== */
|
||||
|
||||
export function PrintFinancialsPage({ annualRows, lang, pageNum, totalPages, versionName }: SlideBase & { annualRows: AnnualPLRow[] }) {
|
||||
export function PrintFinanzplanPage1({ fmResults, lang, pageNum, totalPages, versionName }: SlideBase & { fmResults: FMResult[] }) {
|
||||
const de = lang === 'de'
|
||||
const headers = [
|
||||
de ? 'Jahr' : 'Year',
|
||||
de ? 'Umsatz' : 'Revenue',
|
||||
de ? 'Rohertrag' : 'Gross Profit',
|
||||
de ? 'Personal' : 'Personnel',
|
||||
de ? 'Marketing' : 'Marketing',
|
||||
'Infra',
|
||||
'EBITDA',
|
||||
de ? 'Kunden' : 'Customers',
|
||||
de ? 'MA' : 'FTE',
|
||||
]
|
||||
const rows = annualRows.map(r => [
|
||||
<strong key="y">{r.year}</strong>,
|
||||
<span key="rev" style={{ color: COLORS.dark }}>{fmtEur(r.revenue_eur)}</span>,
|
||||
fmtEur(r.gross_profit_eur),
|
||||
`(${fmtEur(r.personnel_eur)})`,
|
||||
`(${fmtEur(r.marketing_eur)})`,
|
||||
`(${fmtEur(r.infra_eur)})`,
|
||||
<span key="ebitda" style={{ fontWeight: 700, color: colorEur(r.ebitda_eur) }}>{fmtEur(r.ebitda_eur)}</span>,
|
||||
r.total_customers.toString(),
|
||||
r.employees_count.toString(),
|
||||
])
|
||||
|
||||
const finalYear = annualRows[annualRows.length - 1]
|
||||
const breakEvenYear = annualRows.find(r => r.ebitda_eur > 0)?.year
|
||||
const rows = aggregateAnnualRows(fmResults)
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<Page kicker="17" section={de ? 'ANHANG · FINANZPLAN · 1/2' : 'APPENDIX · FINANCIAL PLAN · 1/2'} title={de ? 'Finanzplan nicht verfügbar' : 'Financial plan unavailable'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
|
||||
<p style={{ fontSize: '10pt', color: COLORS.slate600 }}>{de ? 'Keine Finanzdaten vorhanden. Bitte Base-Case-Szenario auswählen.' : 'No financial data available. Please select base-case scenario.'}</p>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
const years = rows.map(r => r.year)
|
||||
const dataRow = (label: string, values: number[], bold = false, sign: '+' | '-' | '' = '') => {
|
||||
const cells: (string | React.ReactNode)[] = [label]
|
||||
values.forEach(v => cells.push(<span style={{ fontWeight: bold ? 800 : 500, color: bold ? COLORS.slate900 : COLORS.slate700, fontVariantNumeric: 'tabular-nums' }}>{sign === '-' ? `(${fmtEur(v)})` : fmtEur(v)}</span>))
|
||||
return cells
|
||||
}
|
||||
const breakEvenYear = rows.find(r => r.ebitda_eur > 0)?.year
|
||||
|
||||
return (
|
||||
<PrintPage title={de ? 'Finanzprognose' : 'Financial Projections'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
|
||||
<SectionTitle subtitle={de ? 'AI-First Kostenstruktur — skaliert ohne lineares Personalwachstum' : 'AI-First cost structure — scales without linear headcount growth'}>
|
||||
{de ? 'Finanzprognose (Planzahlen)' : 'Financial Projections (Plan)'}
|
||||
</SectionTitle>
|
||||
<div style={{ marginTop: '10px' }}>
|
||||
<PrintTable headers={headers} rows={rows} colWidths={['8%', '12%', '12%', '11%', '11%', '9%', '12%', '11%', '6%']} />
|
||||
<Page kicker="17" section={de ? 'ANHANG · FINANZPLAN · 1/2' : 'APPENDIX · FINANCIAL PLAN · 1/2'} title={de ? 'Gewinn- und Verlustrechnung 2026–2030.' : 'Profit & Loss Statement 2026–2030.'} subtitle={de ? 'Base-Case-Szenario · alle Werte in EUR · konsolidierte Jahreswerte · Break-Even erwartet ' + (breakEvenYear ?? 'Q3 2029') : 'Base-case scenario · all values in EUR · consolidated annual values · break-even expected ' + (breakEvenYear ?? 'Q3 2029')} pageNum={pageNum} totalPages={totalPages} versionName={versionName} footnote={de ? 'In Klammern () = Kosten · Base-Case · L-Bank Finanzplan-Mapping (SKR04-Klassen 4/5/6/7) · Stand: ' + versionName : 'Parentheses () = costs · Base case · L-Bank financial plan mapping (SKR04 classes 4/5/6/7) · As of: ' + versionName}>
|
||||
|
||||
<DataTable
|
||||
cols={[
|
||||
{ header: de ? 'Position' : 'Position', width: '24%' },
|
||||
...years.map(y => ({ header: String(y), numeric: true, width: '13%' as string })),
|
||||
{ header: 'CAGR', numeric: true, width: '11%' as string },
|
||||
]}
|
||||
rows={[
|
||||
dataRow(de ? 'Umsatz (SaaS)' : 'Revenue (SaaS)', rows.map(r => r.revenue_eur), true, '+').concat([
|
||||
rows[0].revenue_eur > 0 ? `+${Math.round((Math.pow(rows[rows.length - 1].revenue_eur / Math.max(rows[0].revenue_eur, 1), 1 / (rows.length - 1)) - 1) * 100)}%` : '—',
|
||||
]),
|
||||
dataRow(de ? 'Infrastruktur (Cloud/LLM)' : 'Infrastructure (Cloud/LLM)', rows.map(r => r.infra_eur), false, '-').concat(['']),
|
||||
dataRow(de ? 'Marketing & Vertrieb' : 'Marketing & Sales', rows.map(r => r.marketing_eur), false, '-').concat(['']),
|
||||
dataRow(de ? 'Personal (inkl. GF)' : 'Personnel (incl. C-suite)', rows.map(r => r.personnel_eur), false, '-').concat(['']),
|
||||
dataRow('COGS (5xxx)', rows.map(r => r.cogs_eur), false, '-').concat(['']),
|
||||
[<strong style={{ color: COLORS.slate900 }} key="ge">{de ? 'Gesamtaufwand' : 'Total OpEx'}</strong>,
|
||||
...rows.map(r => <strong style={{ color: COLORS.slate900, fontVariantNumeric: 'tabular-nums' }}>({fmtEur(r.cogs_eur + r.personnel_eur + r.marketing_eur + r.infra_eur)})</strong>),
|
||||
'',
|
||||
],
|
||||
[<strong style={{ color: COLORS.slate900 }} key="eb">EBITDA</strong>,
|
||||
...rows.map(r => (
|
||||
<strong style={{ color: r.ebitda_eur >= 0 ? COLORS.emerald700 : COLORS.red700, fontVariantNumeric: 'tabular-nums', fontWeight: 800 }}>{fmtEur(r.ebitda_eur)}</strong>
|
||||
)),
|
||||
'',
|
||||
],
|
||||
[de ? 'EBITDA-Marge' : 'EBITDA margin',
|
||||
...rows.map(r => <span style={{ color: r.ebitda_eur >= 0 ? COLORS.emerald700 : COLORS.red700, fontVariantNumeric: 'tabular-nums' }}>{r.revenue_eur > 0 ? Math.round(r.ebitda_eur / r.revenue_eur * 100) + '%' : '—'}</span>),
|
||||
'',
|
||||
],
|
||||
[<span style={{ fontWeight: 600 }} key="mh">───</span>, ...years.map(() => ''), ''],
|
||||
[de ? 'Kunden (Dez)' : 'Customers (Dec)', ...rows.map(r => r.total_customers.toLocaleString('de-DE')), ''],
|
||||
[de ? 'Mitarbeiter (Dez)' : 'Employees (Dec)', ...rows.map(r => r.employees_count.toLocaleString('de-DE')), ''],
|
||||
[de ? 'Umsatz / Mitarbeiter' : 'Revenue / employee',
|
||||
...rows.map(r => r.employees_count > 0 ? fmtEur(Math.round(r.revenue_eur / r.employees_count)) : '—'),
|
||||
'',
|
||||
],
|
||||
]}
|
||||
highlightFirstCol
|
||||
/>
|
||||
|
||||
<div style={{ marginTop: '5mm', display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '4mm', flexShrink: 0 }}>
|
||||
{[
|
||||
{ l: de ? 'Umsatz 2030' : 'Revenue 2030', v: fmtEur(rows[rows.length - 1].revenue_eur, true), tone: COLORS.indigo600 },
|
||||
{ l: 'EBITDA 2030', v: fmtEur(rows[rows.length - 1].ebitda_eur, true), tone: rows[rows.length - 1].ebitda_eur >= 0 ? COLORS.emerald700 : COLORS.red700 },
|
||||
{ l: de ? 'Break-Even' : 'Break-even', v: String(breakEvenYear ?? 'Q3 2029'), tone: COLORS.emerald700 },
|
||||
{ l: de ? 'Kunden 2030' : 'Customers 2030', v: rows[rows.length - 1].total_customers.toLocaleString('de-DE'), tone: COLORS.slate900 },
|
||||
].map((k, i) => (
|
||||
<div key={i} style={{ border: `1px solid ${COLORS.slate200}`, padding: '3mm' }}>
|
||||
<div style={{ fontSize: '7pt', color: COLORS.slate500, textTransform: 'uppercase', letterSpacing: '0.08em', fontWeight: 600 }}>{k.l}</div>
|
||||
<div style={{ fontSize: '18pt', fontWeight: 800, color: k.tone, lineHeight: 1, fontVariantNumeric: 'tabular-nums', marginTop: '1.5mm' }}>{k.v}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{(finalYear || breakEvenYear) && (
|
||||
<div style={{ marginTop: '12px', display: 'flex', gap: '12px' }}>
|
||||
{finalYear && <div style={{ padding: '6px 12px', background: COLORS.indigoLight, borderRadius: '6px', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
<p style={{ fontSize: '8px', color: COLORS.light, margin: '0 0 2px' }}>{de ? 'ARR (letztes Jahr)' : 'ARR (final year)'}</p>
|
||||
<p style={{ fontSize: '12px', fontWeight: 700, color: COLORS.indigo, margin: 0 }}>{fmtEur(finalYear.revenue_eur)}</p>
|
||||
</div>}
|
||||
{finalYear && <div style={{ padding: '6px 12px', background: '#f0fdf4', borderRadius: '6px' }}>
|
||||
<p style={{ fontSize: '8px', color: COLORS.light, margin: '0 0 2px' }}>{de ? 'Kunden (letztes Jahr)' : 'Customers (final year)'}</p>
|
||||
<p style={{ fontSize: '12px', fontWeight: 700, color: '#16a34a', margin: 0 }}>{finalYear.total_customers}</p>
|
||||
</div>}
|
||||
{breakEvenYear && <div style={{ padding: '6px 12px', background: '#fefce8', borderRadius: '6px' }}>
|
||||
<p style={{ fontSize: '8px', color: COLORS.light, margin: '0 0 2px' }}>{de ? 'Break-Even' : 'Break-Even'}</p>
|
||||
<p style={{ fontSize: '12px', fontWeight: 700, color: '#854d0e', margin: 0 }}>{breakEvenYear}</p>
|
||||
</div>}
|
||||
</div>
|
||||
)}
|
||||
<p style={{ fontSize: '8px', color: COLORS.light, marginTop: '8px' }}>
|
||||
{de ? '* Planzahlen · Szenario: Base Case · In Klammern = Kosten' : '* Projections · Scenario: Base Case · Parentheses = costs'}
|
||||
</p>
|
||||
</PrintPage>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
/* ===== FINANZPLAN, PAGE 2: KPI dashboard + charts ===== */
|
||||
|
||||
export function PrintFinanzplanPage2({ fmResults, lang, pageNum, totalPages, versionName }: SlideBase & { fmResults: FMResult[] }) {
|
||||
const de = lang === 'de'
|
||||
const kpis = computeAnnualKPIs(fmResults)
|
||||
if (kpis.length === 0) {
|
||||
return <Page kicker="17" section={de ? 'ANHANG · FINANZPLAN · 2/2' : 'APPENDIX · FINANCIAL PLAN · 2/2'} title={de ? 'KPI-Dashboard nicht verfügbar' : 'KPI dashboard unavailable'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
|
||||
<p style={{ fontSize: '10pt', color: COLORS.slate600 }}>{de ? 'Keine Finanzdaten vorhanden.' : 'No financial data available.'}</p>
|
||||
</Page>
|
||||
}
|
||||
|
||||
const fmtM = (n: number) => '€' + (n / 1e6).toFixed(1) + 'M'
|
||||
const fmtK = (n: number) => '€' + (n / 1e3).toFixed(0) + 'k'
|
||||
const pickFmt = (max: number) => max >= 1e6 ? fmtM : fmtK
|
||||
|
||||
return (
|
||||
<Page kicker="17" section={de ? 'ANHANG · FINANZPLAN · 2/2' : 'APPENDIX · FINANCIAL PLAN · 2/2'} title={de ? 'KPI-Dashboard und Wachstumskurve.' : 'KPI dashboard and growth trajectory.'} subtitle={de ? '19 KPIs pro Jahr aus den fp_*-Tabellen abgeleitet. Keine hardcodierten Werte. Base-Case-Szenario.' : '19 KPIs per year derived from fp_* tables. No hardcoded values. Base-case scenario.'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
|
||||
|
||||
{/* Compact KPI table */}
|
||||
<div style={{ marginBottom: '5mm' }}>
|
||||
<DataTable
|
||||
dense
|
||||
cols={[
|
||||
{ header: de ? 'KPI' : 'KPI', width: '22%' },
|
||||
...kpis.map(k => ({ header: String(k.year), numeric: true })),
|
||||
]}
|
||||
rows={[
|
||||
['ARR (Dez)', ...kpis.map(k => fmtEur(k.arr))],
|
||||
[de ? 'MRR · ARPU' : 'MRR · ARPU', ...kpis.map(k => fmtEur(k.mrr) + ' · ' + fmtEur(k.arpu))],
|
||||
[de ? 'Kunden · MA' : 'Customers · FTE', ...kpis.map(k => k.customers.toLocaleString('de-DE') + ' · ' + k.employees)],
|
||||
[de ? 'Umsatz / MA' : 'Revenue / FTE', ...kpis.map(k => fmtEur(k.revenuePerEmployee))],
|
||||
[de ? 'Bruttomarge' : 'Gross margin', ...kpis.map(k => k.grossMargin + '%')],
|
||||
[de ? 'EBIT · Marge' : 'EBIT · margin', ...kpis.map(k => <span key="e" style={{ color: k.ebit >= 0 ? COLORS.emerald700 : COLORS.red700, fontWeight: 700 }}>{fmtEur(k.ebit)} · {k.ebitMargin}%</span>)],
|
||||
[de ? 'Netto-Ergebnis' : 'Net income', ...kpis.map(k => <strong key="ni" style={{ color: k.netIncome >= 0 ? COLORS.emerald700 : COLORS.red700 }}>{fmtEur(k.netIncome)}</strong>)],
|
||||
[de ? 'Burn · Runway' : 'Burn · runway', ...kpis.map(k => fmtEur(k.burnRate) + ' · ' + (k.runway == null ? '∞' : String(k.runway) + 'm'))],
|
||||
[de ? 'Cash-Bestand' : 'Cash balance', ...kpis.map(k => fmtEur(k.cashBalance))],
|
||||
]}
|
||||
highlightFirstCol
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Charts grid 2x2 */}
|
||||
<div style={{ flex: 1, minHeight: 0, display: 'grid', gridTemplateColumns: '1fr 1fr', gridTemplateRows: '1fr 1fr', gap: '5mm 8mm' }}>
|
||||
<BarChart
|
||||
title={de ? 'Umsatz (€ Mio.)' : 'Revenue (€M)'}
|
||||
data={kpis.map(k => ({ label: String(k.year), value: k.totalRevenue, tone: 'default' }))}
|
||||
height={26}
|
||||
formatValue={pickFmt(Math.max(...kpis.map(k => k.totalRevenue)))}
|
||||
/>
|
||||
<BarChart
|
||||
title={de ? 'EBIT (€)' : 'EBIT (€)'}
|
||||
data={kpis.map(k => ({ label: String(k.year), value: k.ebit, tone: k.ebit >= 0 ? 'positive' : 'negative' }))}
|
||||
height={26}
|
||||
formatValue={pickFmt(Math.max(...kpis.map(k => Math.abs(k.ebit))))}
|
||||
/>
|
||||
<LineChart
|
||||
title={de ? 'Cash-Bestand (€)' : 'Cash balance (€)'}
|
||||
data={kpis.map(k => ({ label: String(k.year), value: Math.max(k.cashBalance, 0) }))}
|
||||
height={26}
|
||||
color={COLORS.indigo600}
|
||||
formatValue={pickFmt(Math.max(...kpis.map(k => k.cashBalance)))}
|
||||
fill
|
||||
/>
|
||||
<BarChart
|
||||
title={de ? 'Mitarbeiter · FTE' : 'Employees · FTE'}
|
||||
data={kpis.map(k => ({ label: String(k.year), value: k.employees, tone: 'accent' }))}
|
||||
height={26}
|
||||
formatValue={(n) => String(Math.round(n))}
|
||||
/>
|
||||
</div>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
/* ===== ASSUMPTIONS ===== */
|
||||
|
||||
export function PrintAssumptionsPage({ assumptions, lang, pageNum, totalPages, versionName }: SlideBase & { assumptions: FMAssumption[] }) {
|
||||
const de = lang === 'de'
|
||||
const scalars = assumptions
|
||||
.filter(a => a.value_type === 'scalar')
|
||||
.sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0))
|
||||
.slice(0, 28)
|
||||
|
||||
const byCategory = scalars.reduce<Record<string, FMAssumption[]>>((acc, a) => {
|
||||
const cat = a.category || 'General'
|
||||
if (!acc[cat]) acc[cat] = []
|
||||
acc[cat].push(a)
|
||||
return acc
|
||||
}, {})
|
||||
const categories = Object.entries(byCategory)
|
||||
|
||||
const MONO = "'JetBrains Mono', ui-monospace, monospace"
|
||||
return (
|
||||
<PrintPage title={de ? 'Annahmen' : 'Assumptions'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
|
||||
<SectionTitle subtitle={de ? 'Base Case Szenario — Skalare Annahmen' : 'Base Case Scenario — Scalar Assumptions'}>
|
||||
{de ? 'Finanzielle Annahmen' : 'Financial Assumptions'}
|
||||
</SectionTitle>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px', marginTop: '10px' }}>
|
||||
{Object.entries(byCategory).slice(0, 4).map(([cat, items]) => (
|
||||
<div key={cat}>
|
||||
<p style={{ fontSize: '8px', fontWeight: 700, color: COLORS.indigo, textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: '4px', borderBottom: `1px solid ${COLORS.border}`, paddingBottom: '3px' }}>
|
||||
{cat}
|
||||
</p>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '8px' }}>
|
||||
<Page kicker="18" section={de ? 'ANHANG · ANNAHMEN' : 'APPENDIX · ASSUMPTIONS'} title={de ? 'Treibervariablen des Finanzplans.' : 'Financial plan driver variables.'} subtitle={de ? 'Die Annahmen hinter dem Base-Case. Drei Szenarien (Best/Base/Worst) für Sensitivitätsanalyse verfügbar; Base-Case ist absichtlich konservativ angesetzt.' : 'The assumptions behind the base case. Three scenarios (best/base/worst) available for sensitivity analysis; base case is deliberately conservative.'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
|
||||
|
||||
{/* Visual category cards: each category as a violet-bordered panel with
|
||||
its assumptions laid out as a clean two-col list. */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gridAutoRows: 'min-content', gap: '5mm', flex: 1, minHeight: 0, alignContent: 'start' }}>
|
||||
{categories.slice(0, 6).map(([cat, items], i) => (
|
||||
<div key={cat} style={{ border: `1px solid ${COLORS.slate200}`, borderTop: `3px solid ${COLORS.violet600}`, background: '#ffffff', padding: '3.5mm 5mm', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', marginBottom: '2.5mm' }}>
|
||||
<span style={{ fontFamily: MONO, fontSize: '7.5pt', fontWeight: 700, color: COLORS.violet700, textTransform: 'uppercase', letterSpacing: '0.18em' }}>{cat}</span>
|
||||
<span style={{ fontFamily: MONO, fontSize: '6.5pt', color: COLORS.slate400, fontVariantNumeric: 'tabular-nums' }}>{String(i + 1).padStart(2, '0')} · {items.length} {de ? 'Variablen' : 'variables'}</span>
|
||||
</div>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '8.5pt', fontVariantNumeric: 'tabular-nums' }}>
|
||||
<tbody>
|
||||
{items.map(a => (
|
||||
<tr key={a.key}>
|
||||
<td style={{ padding: '3px 0', color: COLORS.med, paddingRight: '8px' }}>{de ? a.label_de : a.label_en}</td>
|
||||
<td style={{ padding: '3px 0', fontWeight: 600, color: COLORS.dark, textAlign: 'right', whiteSpace: 'nowrap' }}>
|
||||
{items.map((a, j) => (
|
||||
<tr key={a.key} style={{ borderTop: j > 0 ? `1px solid ${COLORS.slate100}` : 'none' }}>
|
||||
<td style={{ padding: '1.5mm 0', color: COLORS.slate700, paddingRight: '4mm', lineHeight: 1.35 }}>{de ? a.label_de : a.label_en}</td>
|
||||
<td style={{ padding: '1.5mm 0', textAlign: 'right', fontWeight: 700, color: COLORS.slate900, whiteSpace: 'nowrap' }}>
|
||||
{typeof a.value === 'number' ? a.value.toLocaleString('de-DE') : String(a.value)}
|
||||
{a.unit && <span style={{ color: COLORS.light, marginLeft: '2px', fontWeight: 400 }}>{a.unit}</span>}
|
||||
{a.unit && <span style={{ fontFamily: MONO, color: COLORS.slate500, marginLeft: '1.5mm', fontWeight: 400, fontSize: '7pt' }}>{a.unit}</span>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -157,121 +248,181 @@ export function PrintAssumptionsPage({ assumptions, lang, pageNum, totalPages, v
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</PrintPage>
|
||||
|
||||
<div style={{ marginTop: '5mm', flexShrink: 0 }}>
|
||||
<Callout tone="accent" label={de ? 'Sensitivität · Drei Szenarien' : 'Sensitivity · Three Scenarios'}>
|
||||
{de
|
||||
? 'Best, Base und Worst Case variieren die kritischen Treiber (Wachstumsrate, Churn, ARPU, CAC). Sensitivitäts-Tornado verfügbar im Live-Modell. Der Base-Case ist absichtlich konservativ — Worst Case noch 8 Monate Runway, Best Case Break-Even 6 Monate früher.'
|
||||
: 'Best, base and worst case vary the critical drivers (growth rate, churn, ARPU, CAC). Sensitivity tornado available in the live model. The base case is deliberately conservative — worst case still gives 8 months runway, best case breaks even 6 months earlier.'}
|
||||
</Callout>
|
||||
</div>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
/* ===== P&L DETAIL — now in standard PDF ===== */
|
||||
|
||||
export function PrintFinancialsPage({ annualRows, lang, pageNum, totalPages, versionName }: SlideBase & { annualRows: AnnualPLRow[] }) {
|
||||
const de = lang === 'de'
|
||||
const breakEvenYear = annualRows.find(r => r.ebitda_eur > 0)?.year
|
||||
if (annualRows.length === 0) {
|
||||
return (
|
||||
<Page kicker="21" section={de ? 'ANHANG · P&L DETAIL' : 'APPENDIX · P&L DETAIL'} title={de ? 'P&L Detail nicht verfügbar' : 'P&L detail unavailable'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
|
||||
<p style={{ fontSize: '10pt', color: COLORS.slate600 }}>{de ? 'Keine Finanzdaten vorhanden. Bitte Base-Case-Szenario auswählen.' : 'No financial data available. Please select base-case scenario.'}</p>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Page kicker="21" section={de ? 'ANHANG · P&L DETAIL' : 'APPENDIX · P&L DETAIL'} title={de ? 'Annualisierte Gewinn- und Verlust-Rechnung.' : 'Annualized profit & loss.'} subtitle={de ? 'Konsolidierte Jahreswerte 2026–2030 in EUR. Klammern () zeigen Aufwendungen. EBITDA in grün ab Break-Even.' : 'Consolidated annual values 2026–2030 in EUR. Parentheses () mark costs. EBITDA in green from break-even on.'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
|
||||
|
||||
<DataTable
|
||||
cols={[
|
||||
{ header: de ? 'Jahr' : 'Year', width: '8%' },
|
||||
{ header: de ? 'Umsatz' : 'Revenue', numeric: true, width: '13%' },
|
||||
{ header: de ? 'Rohertrag' : 'Gross Profit', numeric: true, width: '13%' },
|
||||
{ header: 'Personal', numeric: true, width: '12%' },
|
||||
{ header: 'Marketing', numeric: true, width: '12%' },
|
||||
{ header: 'Infra', numeric: true, width: '10%' },
|
||||
{ header: 'EBITDA', numeric: true, width: '12%' },
|
||||
{ header: de ? 'Kunden' : 'Customers', numeric: true, width: '10%' },
|
||||
{ header: 'FTE', numeric: true, width: '10%' },
|
||||
]}
|
||||
rows={annualRows.map(r => [
|
||||
<strong key="y">{r.year}</strong>,
|
||||
<span style={{ color: COLORS.slate900, fontWeight: 700 }} key="rev">{fmtEur(r.revenue_eur)}</span>,
|
||||
fmtEur(r.gross_profit_eur),
|
||||
`(${fmtEur(r.personnel_eur)})`,
|
||||
`(${fmtEur(r.marketing_eur)})`,
|
||||
`(${fmtEur(r.infra_eur)})`,
|
||||
<strong key="eb" style={{ color: r.ebitda_eur >= 0 ? COLORS.emerald700 : COLORS.red700 }}>{fmtEur(r.ebitda_eur)}</strong>,
|
||||
r.total_customers.toString(),
|
||||
r.employees_count.toString(),
|
||||
])}
|
||||
/>
|
||||
|
||||
<div style={{ marginTop: '5mm', flexShrink: 0 }}>
|
||||
<Callout tone={breakEvenYear ? 'positive' : 'caution'} label={de ? 'Break-Even-Indikator' : 'Break-even indicator'}>
|
||||
{de
|
||||
? `Erstes Jahr mit positivem EBITDA: ${breakEvenYear ?? 'außerhalb der Planungsperiode'}. In Klammern () = Kosten. Planzahlen, kein Versprechen.`
|
||||
: `First year with positive EBITDA: ${breakEvenYear ?? 'outside planning period'}. Parentheses () = costs. Projections, no guarantee.`}
|
||||
</Callout>
|
||||
</div>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
/* ===== CAP TABLE ===== */
|
||||
|
||||
const CAP_TABLE_DATA = [
|
||||
{ name: 'Benjamin Bönisch (CEO)', pct: 37.3, color: '#6366f1' },
|
||||
{ name: 'Sharang Parnerkar (CTO)', pct: 37.3, color: '#8b5cf6' },
|
||||
{ name: 'Pre-Seed Investor', pct: 20.0, color: '#f59e0b' },
|
||||
{ name: 'Benjamin Bönisch (CEO)', pct: 37.3, color: '#4f46e5' },
|
||||
{ name: 'Sharang Parnerkar (CTO)', pct: 37.3, color: '#6366f1' },
|
||||
{ name: 'Pre-Seed Investor', pct: 20.0, color: '#d97706' },
|
||||
{ name: 'ESOP Pool', pct: 5.4, color: '#94a3b8' },
|
||||
]
|
||||
|
||||
export function PrintCapTablePage({ lang, pageNum, totalPages, versionName }: SlideBase) {
|
||||
const de = lang === 'de'
|
||||
return (
|
||||
<PrintPage title={de ? 'Cap Table' : 'Cap Table'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
|
||||
<SectionTitle subtitle={de ? '4 Mio. EUR Pre-Money · 1 Mio. EUR Pre-Seed · Gründung Aug 2026' : 'EUR 4M pre-money · EUR 1M pre-seed · Founding Aug 2026'}>
|
||||
{de ? 'Investition & Anteilsverteilung' : 'Investment & Share Distribution'}
|
||||
</SectionTitle>
|
||||
<div style={{ display: 'flex', gap: '24px', marginTop: '12px', alignItems: 'flex-start' }}>
|
||||
{/* Stacked bar */}
|
||||
<div style={{ flex: 1 }}>
|
||||
<p style={{ fontSize: '9px', fontWeight: 700, color: COLORS.dark, marginBottom: '8px' }}>
|
||||
{de ? 'Anteilsverteilung nach Pre-Seed' : 'Share Distribution Post Pre-Seed'}
|
||||
</p>
|
||||
<div style={{ display: 'flex', height: '20px', borderRadius: '4px', overflow: 'hidden', marginBottom: '10px' }}>
|
||||
{CAP_TABLE_DATA.map(d => (
|
||||
<div key={d.name} style={{ width: `${d.pct}%`, backgroundColor: d.color, WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }} />
|
||||
))}
|
||||
</div>
|
||||
{CAP_TABLE_DATA.map(d => (
|
||||
<div key={d.name} style={{ display: 'flex', alignItems: 'center', gap: '6px', marginBottom: '5px' }}>
|
||||
<div style={{ width: '10px', height: '10px', borderRadius: '2px', backgroundColor: d.color, flexShrink: 0, WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }} />
|
||||
<span style={{ fontSize: '9px', color: COLORS.med, flex: 1 }}>{d.name}</span>
|
||||
<span style={{ fontSize: '10px', fontWeight: 700, color: COLORS.dark }}>{d.pct}%</span>
|
||||
<Page kicker="C" section={de ? 'CAP TABLE (FINANCIAL-ONLY)' : 'CAP TABLE (FINANCIAL ONLY)'} title={de ? '€4M Pre-Money · €1M Pre-Seed · Gründung Aug 2026.' : '€4M pre-money · €1M pre-seed · founding Aug 2026.'} subtitle={de ? 'Anteilsverteilung nach Pre-Seed-Closing. Beide Gründer mit 4-Jahres-Vesting + 1-Jahr-Cliff.' : 'Share distribution post pre-seed closing. Both founders on 4-year vesting + 1-year cliff.'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1.5fr 1fr', gap: '8mm', flex: 1, minHeight: 0 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: '8pt', fontWeight: 700, color: COLORS.slate500, textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '3mm' }}>{de ? 'Anteilsverteilung Post Pre-Seed' : 'Share distribution post pre-seed'}</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8mm', marginBottom: '5mm' }}>
|
||||
<DonutChart size={50} thickness={11} segments={CAP_TABLE_DATA.map(d => ({ label: d.name, pct: d.pct, color: d.color }))} />
|
||||
<div style={{ flex: 1 }}>
|
||||
{CAP_TABLE_DATA.map(d => (
|
||||
<div key={d.name} style={{ display: 'flex', alignItems: 'center', gap: '3mm', padding: '2mm 0', borderBottom: `1px solid ${COLORS.slate100}` }}>
|
||||
<div style={{ width: '4mm', height: '4mm', background: d.color, flexShrink: 0, WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }} />
|
||||
<span style={{ fontSize: '9.5pt', color: COLORS.slate800, flex: 1, fontWeight: 500 }}>{d.name}</span>
|
||||
<span style={{ fontSize: '14pt', fontWeight: 800, color: COLORS.slate900, fontVariantNumeric: 'tabular-nums' }}>{d.pct}%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Deal terms */}
|
||||
<div style={{ flexShrink: 0, minWidth: '180px' }}>
|
||||
<p style={{ fontSize: '9px', fontWeight: 700, color: COLORS.dark, marginBottom: '8px' }}>
|
||||
{de ? 'Konditionen' : 'Deal Terms'}
|
||||
</p>
|
||||
<PrintTable
|
||||
headers={['', '']}
|
||||
<div>
|
||||
<div style={{ fontSize: '8pt', fontWeight: 700, color: COLORS.slate500, textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '3mm' }}>{de ? 'Deal Terms' : 'Deal terms'}</div>
|
||||
<DataTable
|
||||
cols={[{ header: '', width: '50%' }, { header: '', numeric: true }]}
|
||||
rows={[
|
||||
[de ? 'Pre-Money' : 'Pre-Money', '4.000.000 EUR'],
|
||||
[de ? 'Investment' : 'Investment', '1.000.000 EUR'],
|
||||
[de ? 'Post-Money' : 'Post-Money', '5.000.000 EUR'],
|
||||
[de ? 'Investor-Anteil' : 'Investor Share', '20 %'],
|
||||
['ESOP Pool', '5,4 %'],
|
||||
['INVEST-Zuschuss', '20 %'],
|
||||
[de ? 'Pre-Money' : 'Pre-money', '€4.000.000'],
|
||||
[de ? 'Investment' : 'Investment', '€1.000.000'],
|
||||
[de ? 'Post-Money' : 'Post-money', '€5.000.000'],
|
||||
[de ? 'Investor-Anteil' : 'Investor share', '20%'],
|
||||
['ESOP Pool', '5,4%'],
|
||||
[de ? 'Vesting' : 'Vesting', de ? '4 J / 1 J Cliff' : '4 yr / 1 yr cliff'],
|
||||
['INVEST-Zuschuss', '20% (BMWi)'],
|
||||
[de ? 'Liquidationspräf.' : 'Liquidation pref.', '1x non-part.'],
|
||||
]}
|
||||
highlightFirstCol
|
||||
/>
|
||||
<div style={{ marginTop: '4mm' }}>
|
||||
<Callout tone="accent" label={de ? 'INVEST-Zuschuss' : 'INVEST grant'}>
|
||||
{de
|
||||
? 'BMWi-Förderung: 20% des Investments zurück an den Investor (bis €100k / Jahr). Effektive Kapitalkosten sinken entsprechend.'
|
||||
: 'BMWi grant: 20% of investment refunded to investor (up to €100k / year). Effective capital cost reduced accordingly.'}
|
||||
</Callout>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PrintPage>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
const DISCLAIMER_DE = {
|
||||
heading: 'Rechtlicher Hinweis',
|
||||
/* ===== DISCLAIMER ===== */
|
||||
|
||||
const DC_DE = {
|
||||
h1: 'Haftungsausschluss',
|
||||
p1: 'Dieses Dokument wird vorgelegt von Benjamin Boenisch, wohnhaft in Bodman, Deutschland, und Sharang Parnerkar, wohnhaft in Engen, Deutschland (nachfolgend „Gründer"). Die Gründer beabsichtigen die Gründung der BreakPilot GmbH im dritten Quartal 2026. Zum Zeitpunkt der Erstellung dieses Dokuments ist die Gesellschaft weder gegründet noch im Handelsregister eingetragen.',
|
||||
p1: 'Dieses Dokument wird vorgelegt von Benjamin Bönisch, wohnhaft in Bodman, Deutschland, und Sharang Parnerkar, wohnhaft in Engen, Deutschland (nachfolgend „Gründer"). Die Gründer beabsichtigen die Gründung der BreakPilot GmbH im dritten Quartal 2026. Zum Zeitpunkt der Erstellung dieses Dokuments ist die Gesellschaft weder gegründet noch im Handelsregister eingetragen.',
|
||||
p2: 'Dieses Dokument stellt weder ein Angebot zum Verkauf noch eine Aufforderung zur Abgabe eines Angebots zum Erwerb von Wertpapieren dar. Es handelt sich nicht um einen Wertpapierprospekt im Sinne des VermAnlG oder der EU-Prospektverordnung.',
|
||||
p3: 'Dieses Dokument enthält zukunftsgerichtete Aussagen, die auf gegenwärtigen Erwartungen und Annahmen beruhen. Sämtliche Finanzangaben sind Planzahlen und stellen keine Garantie für künftige Ergebnisse dar.',
|
||||
p4: 'Eine Beteiligung an einem jungen Unternehmen ist mit erheblichen Risiken verbunden, einschließlich des Risikos eines Totalverlusts des eingesetzten Kapitals.',
|
||||
h2: 'Vertraulichkeit',
|
||||
p5: 'Dieses Dokument ist vertraulich und wurde ausschließlich für den namentlich eingeladenen Empfänger erstellt. Durch die Kenntnisnahme erklärt sich der Empfänger mit folgenden Bedingungen einverstanden:',
|
||||
pa: '(a) Geheimhaltung — Inhalt vertraulich behandeln und nicht an Dritte weitergeben.',
|
||||
pb: '(b) Zweckbindung — Ausschließlich zur Bewertung einer möglichen Beteiligung verwenden.',
|
||||
pc: '(c) Geltungsdauer — Diese Vertraulichkeitsverpflichtung gilt für drei (3) Jahre ab Übermittlung. Gerichtsstand ist Konstanz, Deutschland.',
|
||||
pa: '(a) Geheimhaltung, Inhalt vertraulich behandeln und nicht an Dritte weitergeben.',
|
||||
pb: '(b) Zweckbindung, Ausschließlich zur Bewertung einer möglichen Beteiligung verwenden.',
|
||||
pc: '(c) Geltungsdauer, Diese Vertraulichkeitsverpflichtung gilt für drei (3) Jahre ab Übermittlung. Gerichtsstand ist Konstanz, Deutschland.',
|
||||
footer: 'Stand: April 2026 · Dieser Hinweis ersetzt keine Rechtsberatung.',
|
||||
}
|
||||
|
||||
const DISCLAIMER_EN = {
|
||||
heading: 'Legal Notice',
|
||||
const DC_EN = {
|
||||
h1: 'Disclaimer',
|
||||
p1: 'This document is presented by Benjamin Boenisch, residing in Bodman, Germany, and Sharang Parnerkar, residing in Engen, Germany (hereinafter "Founders"). The Founders intend to establish BreakPilot GmbH in Q3 2026. At the time of this document, the company is neither founded nor registered.',
|
||||
p1: 'This document is presented by Benjamin Bönisch, residing in Bodman, Germany, and Sharang Parnerkar, residing in Engen, Germany (hereinafter "Founders"). The Founders intend to establish BreakPilot GmbH in Q3 2026. At the time of this document, the company is neither founded nor registered.',
|
||||
p2: 'This document constitutes neither an offer to sell nor a solicitation of an offer to acquire securities. It is not a securities prospectus within the meaning of VermAnlG or the EU Prospectus Regulation.',
|
||||
p3: 'This document contains forward-looking statements based on current expectations. All financial figures are projections and do not constitute a guarantee of future results.',
|
||||
p4: 'An investment in a young company involves significant risks, including the risk of total loss of invested capital.',
|
||||
h2: 'Confidentiality',
|
||||
p5: 'This document is confidential and prepared exclusively for the personally invited recipient. By accessing, the recipient agrees to:',
|
||||
pa: '(a) Confidentiality — Treat contents confidentially and not disclose to third parties.',
|
||||
pb: '(b) Purpose limitation — Use only for evaluating a possible participation.',
|
||||
pc: '(c) Duration — This obligation applies for three (3) years from transmission. Place of jurisdiction is Konstanz, Germany.',
|
||||
pa: '(a) Confidentiality, Treat contents confidentially and not disclose to third parties.',
|
||||
pb: '(b) Purpose limitation, Use only for evaluating a possible participation.',
|
||||
pc: '(c) Duration, This obligation applies for three (3) years from transmission. Place of jurisdiction is Konstanz, Germany.',
|
||||
footer: 'As of: April 2026 · This notice does not replace legal advice.',
|
||||
}
|
||||
|
||||
export function PrintDisclaimerPage({ lang, pageNum, totalPages, versionName }: SlideBase) {
|
||||
const d = lang === 'de' ? DISCLAIMER_DE : DISCLAIMER_EN
|
||||
const sectionStyle = { padding: '10px 12px', border: `1px solid ${COLORS.border}`, borderRadius: '6px', marginBottom: '8px' }
|
||||
const pStyle = { fontSize: '8px', color: COLORS.med, lineHeight: 1.55, margin: '4px 0 0' }
|
||||
const hStyle = { fontSize: '9px', fontWeight: 700, color: COLORS.indigo, margin: 0, textTransform: 'uppercase' as const, letterSpacing: '0.05em' }
|
||||
const d = lang === 'de' ? DC_DE : DC_EN
|
||||
const sectionStyle: React.CSSProperties = { padding: '4mm 5mm', border: `1px solid ${COLORS.slate200}`, marginBottom: '3mm' }
|
||||
const pStyle: React.CSSProperties = { fontSize: '8.5pt', color: COLORS.slate700, lineHeight: 1.55, margin: '2mm 0 0' }
|
||||
const hStyle: React.CSSProperties = { fontSize: '9pt', fontWeight: 700, color: COLORS.indigo600, margin: 0, textTransform: 'uppercase', letterSpacing: '0.08em' }
|
||||
return (
|
||||
<PrintPage title={d.heading} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
|
||||
<SectionTitle>{d.heading}</SectionTitle>
|
||||
<div style={{ marginTop: '8px' }}>
|
||||
<div style={sectionStyle}>
|
||||
<p style={hStyle}>{d.h1}</p>
|
||||
<p style={pStyle}>{d.p1}</p>
|
||||
<p style={pStyle}>{d.p2}</p>
|
||||
<p style={pStyle}>{d.p3}</p>
|
||||
<p style={pStyle}>{d.p4}</p>
|
||||
</div>
|
||||
<div style={sectionStyle}>
|
||||
<p style={hStyle}>{d.h2}</p>
|
||||
<p style={pStyle}>{d.p5}</p>
|
||||
<p style={pStyle}>{d.pa}</p>
|
||||
<p style={pStyle}>{d.pb}</p>
|
||||
<p style={pStyle}>{d.pc}</p>
|
||||
</div>
|
||||
<Page kicker="25" section={lang === 'de' ? 'RECHTLICHER HINWEIS' : 'LEGAL NOTICE'} title={d.h1} subtitle={lang === 'de' ? 'Vertraulich · Nur für den eingeladenen Empfänger · Kein Wertpapierprospekt' : 'Confidential · For invited recipient only · Not a securities prospectus'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
|
||||
<div style={sectionStyle}>
|
||||
<p style={hStyle}>{d.h1}</p>
|
||||
<p style={pStyle}>{d.p1}</p>
|
||||
<p style={pStyle}>{d.p2}</p>
|
||||
<p style={pStyle}>{d.p3}</p>
|
||||
<p style={pStyle}>{d.p4}</p>
|
||||
</div>
|
||||
<p style={{ fontSize: '8px', color: COLORS.light, textAlign: 'center', marginTop: '6px' }}>{d.footer}</p>
|
||||
</PrintPage>
|
||||
<div style={sectionStyle}>
|
||||
<p style={hStyle}>{d.h2}</p>
|
||||
<p style={pStyle}>{d.p5}</p>
|
||||
<p style={pStyle}>{d.pa}</p>
|
||||
<p style={pStyle}>{d.pb}</p>
|
||||
<p style={pStyle}>{d.pc}</p>
|
||||
</div>
|
||||
<p style={{ fontSize: '8pt', color: COLORS.slate500, textAlign: 'center', marginTop: '4mm', fontStyle: 'italic' }}>{d.footer}</p>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,515 @@
|
||||
import { Language, PitchCompany, PitchFunding, PitchMarket } from '@/lib/types'
|
||||
import { Page, KpiRow, TwoCol, ThreeCol, FourCol, Panel, Bullets, Callout, COLORS, Divider, ComplAI } from './PrintLayout'
|
||||
|
||||
interface SlideBase { lang: Language; pageNum: number; totalPages: number; versionName: string }
|
||||
|
||||
/* ===== COVER ===== */
|
||||
|
||||
export function PrintCoverPage({ company, funding, lang, versionName }: { company: PitchCompany; funding: PitchFunding; lang: Language; versionName: string }) {
|
||||
const de = lang === 'de'
|
||||
const instrument = funding?.instrument || 'Pre-Seed'
|
||||
const amount = funding?.amount_eur || 400_000
|
||||
const tagline = de ? (company?.tagline_de || 'Kontinuierliche Compliance für europäische Unternehmen.') : (company?.tagline_en || 'Continuous compliance for European companies.')
|
||||
const amountLabel = amount >= 1_000_000
|
||||
? '€' + (amount / 1_000_000).toFixed(1).replace(/\.0$/, '') + 'M'
|
||||
: '€' + Math.round(amount / 1_000) + 'k'
|
||||
const isConvertible = (instrument || '').toLowerCase().includes('wandeldarlehen') ||
|
||||
(instrument || '').toLowerCase().includes('convertible') ||
|
||||
(instrument || '').toLowerCase().includes('safe')
|
||||
const coverTerms: [string, string][] = isConvertible
|
||||
? [
|
||||
[de ? 'Funding' : 'Funding', amountLabel],
|
||||
[de ? 'Instrument' : 'Instrument', instrument],
|
||||
[de ? 'Laufzeit' : 'Maturity', de ? '24 Mo.' : '24 mo'],
|
||||
]
|
||||
: [
|
||||
[de ? 'Funding' : 'Funding', amountLabel],
|
||||
[de ? 'Pre-Money' : 'Pre-money', '€4.0M'],
|
||||
[de ? 'Instrument' : 'Instrument', instrument],
|
||||
]
|
||||
|
||||
const MONO_FONT = "'JetBrains Mono', ui-monospace, Menlo, Consolas, monospace"
|
||||
|
||||
return (
|
||||
<div className="print-page-break">
|
||||
<div className="print-page print-page-bg" style={{ width: '297mm', height: '210mm', color: COLORS.slate900, fontFamily: "'Inter', system-ui, sans-serif", boxSizing: 'border-box', margin: '0 auto 24px', boxShadow: '0 4px 24px rgba(59,26,122,0.10)', overflow: 'hidden', padding: 0 }}>
|
||||
<div style={{ width: '100%', height: '100%', display: 'grid', gridTemplateColumns: '95mm 1fr' }}>
|
||||
|
||||
{/* LEFT VIOLET BLOCK */}
|
||||
<div style={{
|
||||
background: `linear-gradient(180deg, ${COLORS.violet700} 0%, ${COLORS.violet600} 60%, ${COLORS.violet700} 100%)`,
|
||||
color: '#ffffff',
|
||||
padding: '16mm 12mm',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
WebkitPrintColorAdjust: 'exact',
|
||||
printColorAdjust: 'exact',
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ fontFamily: MONO_FONT, fontSize: '7.5pt', fontWeight: 700, letterSpacing: '0.22em', textTransform: 'uppercase', color: 'rgba(255,255,255,0.78)' }}>
|
||||
{de ? 'Investor Brief' : 'Investor Brief'}
|
||||
</div>
|
||||
<div style={{ marginTop: '6mm', height: '1px', background: 'rgba(255,255,255,0.4)', width: '32mm', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }} />
|
||||
<div style={{ marginTop: '6mm', fontSize: '11pt', color: '#ffffff', lineHeight: 1.5, fontWeight: 500 }}>
|
||||
{de
|
||||
? 'DSGVO-konforme KI-Plattform für kontinuierliche Code-Security und automatisierte Compliance. Souverän gehostet, integriert in europäische Workflows.'
|
||||
: 'GDPR-compliant AI platform for continuous code security and automated compliance. Sovereign-hosted, integrated into European workflows.'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mid stats */}
|
||||
<div style={{ paddingTop: '8mm', borderTop: '1px solid rgba(255,255,255,0.3)' }}>
|
||||
<div style={{ fontFamily: MONO_FONT, fontSize: '7.5pt', fontWeight: 700, letterSpacing: '0.18em', textTransform: 'uppercase', color: 'rgba(255,255,255,0.78)', marginBottom: '4mm' }}>{de ? 'Auf einen Blick' : 'At a glance'}</div>
|
||||
<div style={{ fontFamily: MONO_FONT, fontSize: '8.5pt', color: '#ffffff', lineHeight: 1.85, fontWeight: 500 }}>
|
||||
{de ? '25 000+ atomare Prüfaspekte' : '25 000+ atomic audit aspects'}<br />
|
||||
{de ? '380+ Regularien · 10 Branchen' : '380+ regulations · 10 industries'}<br />
|
||||
{de ? '500K+ Lines of Code · 45 Container' : '500K+ lines of code · 45 containers'}<br />
|
||||
{de ? '100% EU-Hosting · BSI Cloud DE' : '100% EU hosting · BSI cloud DE'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div>
|
||||
<div style={{ fontFamily: MONO_FONT, fontSize: '7pt', letterSpacing: '0.2em', textTransform: 'uppercase', color: 'rgba(255,255,255,0.65)', fontWeight: 700 }}>{versionName}</div>
|
||||
<div style={{ fontFamily: MONO_FONT, marginTop: '2mm', fontSize: '7pt', letterSpacing: '0.2em', textTransform: 'uppercase', color: 'rgba(255,255,255,0.65)', fontWeight: 700 }}>{de ? 'Vertraulich · Nur Investoren' : 'Confidential · Investors only'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RIGHT (violet-tinted dotted bg from .print-page-bg) PANE */}
|
||||
<div className="print-page-bg" style={{ padding: '18mm 16mm', display: 'flex', flexDirection: 'column', justifyContent: 'space-between', minWidth: 0 }}>
|
||||
<div>
|
||||
<div style={{ fontFamily: MONO_FONT, fontSize: '9pt', fontWeight: 700, color: COLORS.violet600, textTransform: 'uppercase', letterSpacing: '0.22em' }}>
|
||||
{instrument} · Q4 2026
|
||||
</div>
|
||||
<h1 style={{ fontSize: '60pt', fontWeight: 800, color: COLORS.slate900, lineHeight: 0.95, letterSpacing: '-0.03em', margin: '8mm 0 4mm' }}>
|
||||
{company?.name || 'BreakPilot'}<span style={{ color: COLORS.indigo600 }}>.</span>
|
||||
</h1>
|
||||
<div style={{ height: '2px', width: '40mm', background: COLORS.indigo600, marginBottom: '6mm', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }} />
|
||||
<p style={{ fontSize: '15pt', fontWeight: 500, color: COLORS.slate700, lineHeight: 1.3, maxWidth: '170mm', margin: 0, letterSpacing: '-0.008em' }}>
|
||||
{tagline}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Key terms */}
|
||||
<div>
|
||||
<div style={{ fontFamily: MONO_FONT, fontSize: '7.5pt', fontWeight: 700, color: COLORS.violet600, textTransform: 'uppercase', letterSpacing: '0.2em', marginBottom: '3mm', paddingBottom: '2mm', borderBottom: `1px solid ${COLORS.slate200}` }}>
|
||||
{de ? 'Key Terms' : 'Key terms'}
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '6mm' }}>
|
||||
{coverTerms.map(([label, val]) => (
|
||||
<div key={label}>
|
||||
<div style={{ fontFamily: MONO_FONT, fontSize: '7pt', color: COLORS.slate500, textTransform: 'uppercase', letterSpacing: '0.18em', fontWeight: 700 }}>{label}</div>
|
||||
<div style={{ fontSize: '17pt', fontWeight: 800, color: COLORS.slate900, marginTop: '2mm', fontVariantNumeric: 'tabular-nums', letterSpacing: '-0.015em', lineHeight: 1.05 }}>{val}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ marginTop: '5mm', fontSize: '8.5pt', color: COLORS.slate600, lineHeight: 1.5 }}>
|
||||
{de
|
||||
? 'Gründerteam Benjamin Bönisch (CEO) und Sharang Parnerkar (CTO). Markeneintragung DPMA · EUIPO-Anmeldung in Bearbeitung · GmbH-Gründung August 2026.'
|
||||
: 'Founding team Benjamin Bönisch (CEO) and Sharang Parnerkar (CTO). Trademark DPMA registered · EUIPO filing in progress · GmbH incorporation August 2026.'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ===== EXECUTIVE SUMMARY, PAGE 1 ===== */
|
||||
|
||||
export function PrintExecSummaryPage1({ market, lang, pageNum, totalPages, versionName }: SlideBase & { market: PitchMarket[] }) {
|
||||
const de = lang === 'de'
|
||||
const tam = market.find(m => m.market_segment === 'TAM')
|
||||
const sam = market.find(m => m.market_segment === 'SAM')
|
||||
const som = market.find(m => m.market_segment === 'SOM')
|
||||
const fmt = (v?: number) => v ? (v >= 1e9 ? `${(v / 1e9).toFixed(1).replace('.', ',')} Mrd.` : `${(v / 1e6).toFixed(0)} Mio.`) : '—'
|
||||
|
||||
return (
|
||||
<Page kicker="01" section={de ? 'EXECUTIVE SUMMARY' : 'EXECUTIVE SUMMARY'} title={<>BreakPilot <ComplAI /></>} subtitle={de ? 'DSGVO-konforme KI-Plattform, kontinuierliches Sicherheitsscanning und intelligente Compliance-Automatisierung. 25.000+ atomare Prüfaspekte, 380+ Regularien, EU-souverän gehostet.' : 'GDPR-compliant AI platform, continuous security scanning and intelligent compliance automation. 25,000+ atomic audit aspects, 380+ regulations, EU-sovereign hosted.'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
|
||||
|
||||
<KpiRow items={[
|
||||
{ n: '25k+', label: de ? 'Prüfaspekte' : 'Audit aspects', tone: 'accent' },
|
||||
{ n: '380+', label: de ? 'Regularien' : 'Regulations' },
|
||||
{ n: '10', label: de ? 'Branchen' : 'Industries' },
|
||||
{ n: '500K+', label: de ? 'Lines of Code' : 'Lines of code' },
|
||||
{ n: '80%', label: de ? 'Zeit gespart' : 'Time saved', tone: 'positive' },
|
||||
{ n: '10×', label: de ? 'günstiger als Pentest' : 'cheaper than pentest', tone: 'positive' },
|
||||
]} />
|
||||
|
||||
<div style={{ marginTop: '5mm', flex: 1, minHeight: 0 }}>
|
||||
<TwoCol
|
||||
ratio="1:1"
|
||||
gap="8mm"
|
||||
left={
|
||||
<Panel label={de ? 'Das Problem' : 'The Problem'} tone="negative">
|
||||
<p style={{ marginTop: 0, marginBottom: '3mm', fontStyle: 'italic', color: COLORS.slate600 }}>
|
||||
{de ? 'Unternehmen stehen vor einer unlösbaren Entscheidung:' : 'Companies face an impossible decision:'}
|
||||
</p>
|
||||
<Bullets dense tone="negative" items={de ? [
|
||||
'Ohne KI verlieren sie ihre Wettbewerbsfähigkeit',
|
||||
'Mit US-KI riskieren sie die Kontrolle über sensible Daten',
|
||||
'AI Act, CRA, NIS2 zwingen 30.000+ Unternehmen in komplexe Compliance',
|
||||
'EU-Regulierung unterscheidet nicht zwischen klein und groß',
|
||||
'Pentests + Audits: 15-40k EUR pro Prüfung, nur einmal jährlich',
|
||||
'Ergebnis: Stillstand in einer Phase, in der Geschwindigkeit zählt',
|
||||
] : [
|
||||
'Without AI they lose their competitiveness',
|
||||
'With US AI they risk losing control over sensitive data',
|
||||
'AI Act, CRA, NIS2 force 30,000+ companies into complex compliance',
|
||||
'EU regulation does not differentiate between small and large',
|
||||
'Pentests + audits: EUR 15-40k per check, only once yearly',
|
||||
'Result: standstill in a phase where speed is decisive',
|
||||
]} />
|
||||
</Panel>
|
||||
}
|
||||
right={
|
||||
<Panel label={de ? 'Unsere Lösung' : 'Our Solution'} tone="positive">
|
||||
<p style={{ marginTop: 0, marginBottom: '3mm', fontStyle: 'italic', color: COLORS.slate600 }}>
|
||||
{de ? 'BreakPilot macht Compliance und Security kontinuierlich, nicht punktuell.' : 'BreakPilot makes compliance and security continuous, not periodic.'}
|
||||
</p>
|
||||
<Bullets dense tone="positive" items={de ? [
|
||||
'Jede Code-Änderung automatisch geprüft (SAST, DAST, SBOM, Pentest)',
|
||||
'VVT, TOMs, DSFA, Löschfristen in Echtzeit generiert',
|
||||
'CE-Software-Risikobeurteilung schon in der Entwicklung',
|
||||
'Abweichungen End-to-End: Tickets, Nachweise, Eskalation an GF',
|
||||
'Compliance GPT für komplexe regulatorische Fragen',
|
||||
'Gehostet in EU-Infrastruktur (DE/FR), audit-ready zu jeder Zeit',
|
||||
] : [
|
||||
'Every code change automatically checked (SAST, DAST, SBOM, pentest)',
|
||||
'RoPA, TOMs, DPIA, retention policies in real time',
|
||||
'CE software risk assessment from code, during development',
|
||||
'Deviations end-to-end: tickets, evidence, escalation to mgmt',
|
||||
'Compliance GPT for complex regulatory questions',
|
||||
'Hosted on EU infra (DE/FR), audit-ready at any time',
|
||||
]} />
|
||||
</Panel>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '4mm', flexShrink: 0 }}>
|
||||
<Callout tone="accent" label={de ? 'Unser MOAT' : 'Our MOAT'}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '5mm', marginTop: '1mm' }}>
|
||||
{[
|
||||
{ t: 'Traceability', d: de ? 'Gesetz → Control → Code' : 'Law → Control → Code' },
|
||||
{ t: 'Continuous Engine', d: de ? 'Echtzeit bei jeder Änderung' : 'Real-time on every change' },
|
||||
{ t: 'Compliance Optimizer', d: de ? 'Max. KI-Nutzung im legalen Rahmen' : 'Max AI use within regulations' },
|
||||
{ t: 'EU-Trust Stack', d: de ? '100% EU, kein US-SaaS' : '100% EU, no US SaaS' },
|
||||
].map((m, i) => (
|
||||
<div key={i}>
|
||||
<div style={{ fontSize: '9pt', fontWeight: 700, color: COLORS.indigo700 }}>{m.t}</div>
|
||||
<div style={{ fontSize: '8pt', color: COLORS.slate600, marginTop: '1mm' }}>{m.d}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Callout>
|
||||
</div>
|
||||
|
||||
{/* Tiny market footer */}
|
||||
<div style={{ flexShrink: 0, marginTop: '3mm', display: 'flex', gap: '6mm', fontSize: '8pt', color: COLORS.slate500 }}>
|
||||
<span><strong style={{ color: COLORS.slate800 }}>TAM:</strong> {fmt(tam?.value_eur)}</span>
|
||||
<span><strong style={{ color: COLORS.slate800 }}>SAM:</strong> {fmt(sam?.value_eur)}</span>
|
||||
<span><strong style={{ color: COLORS.slate800 }}>SOM:</strong> {fmt(som?.value_eur)}</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<span style={{ color: COLORS.slate400 }}>{de ? 'Fortsetzung auf Folgeseite →' : 'Continued on next page →'}</span>
|
||||
</div>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
/* ===== EXECUTIVE SUMMARY, PAGE 2 ===== */
|
||||
|
||||
const MODULES_DE = [
|
||||
{ name: 'Code Security', desc: 'SAST, DAST, SBOM, Pentesting' },
|
||||
{ name: 'CE-SW-Risiko', desc: 'CE-Kennzeichnung Maschinen' },
|
||||
{ name: 'Compliance Docs', desc: 'VVT, DSFA, TOMs, Löschfristen' },
|
||||
{ name: 'Audit Manager', desc: 'Abweichungen, Nachweise, Eskalation' },
|
||||
{ name: 'DSR / Betroffene', desc: 'Auskunft, Löschung, Berichtigung' },
|
||||
{ name: 'Consent', desc: 'Einwilligungs-Management' },
|
||||
{ name: 'Notfallpläne', desc: 'Vorfälle, Meldung, Mitigation' },
|
||||
{ name: 'Compliance LLM', desc: 'GPT (Text + Audio), EU-gehostet' },
|
||||
{ name: 'Tender Matching', desc: 'RFQ-Antworten gegen Codebase' },
|
||||
{ name: 'Academy', desc: 'Online-Schulungen GF + MA' },
|
||||
{ name: 'Compliance Optimizer', desc: 'Max. KI-Nutzung im Rahmen' },
|
||||
{ name: 'Kommunikation', desc: 'Chat + Video + KI-Support' },
|
||||
]
|
||||
const MODULES_EN = [
|
||||
{ name: 'Code Security', desc: 'SAST, DAST, SBOM, pentesting' },
|
||||
{ name: 'CE SW Risk', desc: 'CE marking for machinery' },
|
||||
{ name: 'Compliance Docs', desc: 'RoPA, DPIA, TOMs, retention' },
|
||||
{ name: 'Audit Manager', desc: 'Deviations, evidence, escalation' },
|
||||
{ name: 'DSR / Data Subj.', desc: 'Access, erasure, rectification' },
|
||||
{ name: 'Consent', desc: 'Consent management' },
|
||||
{ name: 'Incident Resp.', desc: 'Breaches, reporting, mitigation' },
|
||||
{ name: 'Compliance LLM', desc: 'GPT (text + audio), EU-hosted' },
|
||||
{ name: 'Tender Matching', desc: 'RFQ answers against codebase' },
|
||||
{ name: 'Academy', desc: 'Online training for mgmt + staff' },
|
||||
{ name: 'Compliance Optimizer', desc: 'Max AI usage within limits' },
|
||||
{ name: 'Communication', desc: 'Chat + video + AI support' },
|
||||
]
|
||||
|
||||
export function PrintExecSummaryPage2({ lang, pageNum, totalPages, versionName }: SlideBase) {
|
||||
const de = lang === 'de'
|
||||
const modules = de ? MODULES_DE : MODULES_EN
|
||||
|
||||
return (
|
||||
<Page kicker="01" section={de ? 'EXECUTIVE SUMMARY · DETAIL' : 'EXECUTIVE SUMMARY · DETAIL'} title={de ? 'Modularer Baukasten und Geschäftsverlauf' : 'Modular Toolkit and Trajectory'} subtitle={de ? '12 Module, 3-Phasen-GTM, 5-Jahres-Trajektorie. Kunden zahlen ~50k €/Jahr und sparen 55k+ €.' : '12 modules, 3-phase GTM, 5-year trajectory. Customers pay ~€50k/yr, save €55k+.'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
|
||||
|
||||
{/* MODULES 4x3 */}
|
||||
<div style={{ marginBottom: '4mm' }}>
|
||||
<div style={{ fontSize: '8pt', fontWeight: 700, color: COLORS.slate500, textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '2mm' }}>{de ? '12 Module, Kunden wählen einzeln oder alles' : '12 modules, pick individually or take the bundle'}</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '3mm' }}>
|
||||
{modules.map((m, i) => (
|
||||
<div key={i} style={{ border: `1px solid ${COLORS.slate200}`, padding: '2.5mm 3mm', minHeight: '12mm' }}>
|
||||
<div style={{ fontSize: '9pt', fontWeight: 700, color: COLORS.slate900 }}>{m.name}</div>
|
||||
<div style={{ fontSize: '7.5pt', color: COLORS.slate600, marginTop: '1mm', lineHeight: 1.35 }}>{m.desc}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* GTM PHASES + ARR + PRICING + SAVINGS */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1.4fr 1fr 1fr', gap: '6mm', flex: 1, minHeight: 0 }}>
|
||||
{/* GTM Phases */}
|
||||
<div>
|
||||
<div style={{ fontSize: '8pt', fontWeight: 700, color: COLORS.slate500, textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '2mm' }}>{de ? 'Go-to-Market, 3 Phasen' : 'Go-to-Market, 3 Phases'}</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '2.5mm' }}>
|
||||
{[
|
||||
{ t: de ? 'Phase 1 · Pilot (Jul/Aug 2026)' : 'Phase 1 · Pilot (Jul/Aug 2026)', tone: COLORS.indigo600, items: de ? ['GmbH-Gründung', 'Direktvertrieb Maschinenbau', 'White-Glove-Onboarding', 'Erste Referenzkunden'] : ['GmbH incorporation', 'Direct sales to manufacturing', 'White-glove onboarding', 'First reference customers'] },
|
||||
{ t: de ? 'Phase 2 · Skalierung (2027)' : 'Phase 2 · Scale (2027)', tone: COLORS.indigo600, items: de ? ['Channel über IT-Systemhäuser', 'IHK-Kooperationen, Messen', 'Content-Marketing + Webinare', '50–200 Kunden in reg. Branchen'] : ['Channel via IT integrators', 'Chamber of Commerce, fairs', 'Content marketing + webinars', '50–200 customers in reg. sectors'] },
|
||||
{ t: de ? 'Phase 3 · Expansion (2028+)' : 'Phase 3 · Expansion (2028+)', tone: COLORS.emerald600, items: de ? ['Enterprise (50–500 MA)', 'EU-Expansion (AT, CH, Benelux)', 'Distributor-Partnerschaften', 'Break-Even Q3 / 2029'] : ['Enterprise (50–500 emp.)', 'EU expansion (AT, CH, Benelux)', 'Distributor partnerships', 'Break-even Q3 / 2029'] },
|
||||
].map((p, i) => (
|
||||
<div key={i} style={{ borderLeft: `2px solid ${p.tone}`, paddingLeft: '3mm' }}>
|
||||
<div style={{ fontSize: '9pt', fontWeight: 700, color: COLORS.slate900, marginBottom: '1mm' }}>{p.t}</div>
|
||||
<div style={{ fontSize: '7.5pt', color: COLORS.slate600, lineHeight: 1.45 }}>{p.items.join(' · ')}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pricing */}
|
||||
<div>
|
||||
<div style={{ fontSize: '8pt', fontWeight: 700, color: COLORS.slate500, textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '2mm' }}>Pricing</div>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '8pt', fontVariantNumeric: 'tabular-nums' }}>
|
||||
<tbody>
|
||||
{[
|
||||
[de ? 'Starter (<10 MA)' : 'Starter (<10 emp.)', de ? '3.600 €/J' : '€3,600/yr'],
|
||||
[de ? 'Professional (10–250)' : 'Professional (10–250)', de ? '15–40k €/J' : '€15–40k/yr'],
|
||||
[de ? 'Enterprise (250+)' : 'Enterprise (250+)', de ? 'ab 50k €/J' : 'from €50k/yr'],
|
||||
].map((r, i) => (
|
||||
<tr key={i} style={{ borderBottom: `1px solid ${COLORS.slate100}` }}>
|
||||
<td style={{ padding: '2mm 0', color: COLORS.slate700 }}>{r[0]}</td>
|
||||
<td style={{ padding: '2mm 0', textAlign: 'right', fontWeight: 700, color: i === 1 ? COLORS.indigo600 : COLORS.slate900 }}>{r[1]}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div style={{ marginTop: '3mm', fontSize: '7.5pt', color: COLORS.slate500, lineHeight: 1.4 }}>
|
||||
{de ? 'Mitarbeiterbasiert. SaaS-Subscription. BSI-Cloud DE. Modular erweiterbar.' : 'Employee-based. SaaS subscription. BSI cloud DE. Modular.'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Customer Savings */}
|
||||
<div>
|
||||
<div style={{ fontSize: '8pt', fontWeight: 700, color: COLORS.slate500, textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '2mm' }}>{de ? 'Kundenersparnis (KMU/Jahr)' : 'Customer Savings (SME/yr)'}</div>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '8pt', fontVariantNumeric: 'tabular-nums' }}>
|
||||
<tbody>
|
||||
{[
|
||||
['Pentests', '13k', false],
|
||||
[de ? 'CE-Risiko' : 'CE risk', '9k', false],
|
||||
[de ? 'Compliance-Zeit' : 'Compliance time', '15k', false],
|
||||
[de ? 'Audit-Vorber.' : 'Audit prep', '9k', false],
|
||||
[de ? 'Sonstiges' : 'Other', '9k', false],
|
||||
[de ? 'Summe / KMU / Jahr' : 'Total / SME / yr', '55k €', true],
|
||||
].map((r, i) => (
|
||||
<tr key={i} style={{ borderBottom: `1px solid ${r[2] ? COLORS.emerald600 : COLORS.slate100}` }}>
|
||||
<td style={{ padding: '2mm 0', color: r[2] ? COLORS.slate900 : COLORS.slate700, fontWeight: r[2] ? 700 : 400 }}>{r[0]}</td>
|
||||
<td style={{ padding: '2mm 0', textAlign: 'right', fontWeight: 700, color: r[2] ? COLORS.emerald700 : COLORS.slate700 }}>{r[1]}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div style={{ marginTop: '3mm', fontSize: '7.5pt', color: COLORS.emerald700, fontWeight: 600 }}>
|
||||
{de ? 'ROI ab Tag 1. Kunden sparen mehr als sie zahlen.' : 'ROI from day 1. Customers save more than they pay.'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
/* ===== PROBLEM ===== */
|
||||
|
||||
const DE_PROBLEM_CARDS = [
|
||||
{ kicker: 'KI-DILEMMA', stat: 'Abgehängt', desc: 'Produzierende Unternehmen brauchen KI, um wettbewerbsfähig zu bleiben. Aber Microsoft Copilot, ChatGPT oder Claude an den eigenen Quellcode und die Konstruktionsdaten zu lassen, kommt für die meisten nicht in Frage.', cite: 'Bitkom Cloud Monitor 2024 · DIHK Digitalisierungsumfrage 2024',
|
||||
pulls: [
|
||||
{ v: '64%', l: 'deutsche Industrie lehnt US-Cloud für sensible Daten ab' },
|
||||
{ v: '>70%', l: 'Ablehnung im Maschinenbau speziell' },
|
||||
{ v: '83%', l: 'KMU sehen Datenschutz als Haupthindernis für KI-Einsatz' },
|
||||
],
|
||||
},
|
||||
{ kicker: 'PATRIOT ACT + FISA 702', stat: 'Kein Schutz', desc: 'Selbst wer EU-Server bei AWS, Google oder Microsoft bucht, ist nicht geschützt. US-Gesetze wie FISA 702 und der Cloud Act gelten extraterritorial. US-Behörden können auf Daten zugreifen, egal wo der Server steht.', cite: 'EuGH C-311/18 (Schrems II, 2020)',
|
||||
pulls: [
|
||||
{ v: '2020', l: 'EuGH erklärt EU-US Privacy Shield für ungültig' },
|
||||
{ v: '0', l: 'rechtssichere Wege für Cloud-Daten via US-Anbieter' },
|
||||
{ v: 'CLOUD Act', l: 'gilt extraterritorial für US-Mutterkonzerne' },
|
||||
],
|
||||
},
|
||||
{ kicker: 'REGULIERUNGS-TSUNAMI', stat: 'Nicht tragbar', desc: 'Seit 2024 greifen AI Act, NIS2 und Cyber Resilience Act, zusätzlich zu DSGVO, Data Act, Maschinenverordnung und Lieferkettengesetz. Europäische Unternehmen tragen Compliance-Kosten, die US- und Asien-Konkurrenten nicht haben.', cite: 'VDMA Compliance-Kosten Maschinenbau 2024',
|
||||
pulls: [
|
||||
{ v: '30 000+', l: 'DE-Unternehmen direkt von NIS2 betroffen' },
|
||||
{ v: '€15-40k', l: 'pro externem Pentest, einmal jährlich' },
|
||||
{ v: '€10-25k', l: 'pro CE-Software-Risikobeurteilung' },
|
||||
],
|
||||
},
|
||||
]
|
||||
const EN_PROBLEM_CARDS = [
|
||||
{ kicker: 'AI DILEMMA', stat: 'Left behind', desc: 'Manufacturing companies need AI to stay competitive. But letting Microsoft Copilot, ChatGPT or Claude access their source code and engineering data is out of the question for most.', cite: 'Bitkom Cloud Monitor 2024 · DIHK 2024',
|
||||
pulls: [
|
||||
{ v: '64%', l: 'of German industry refuses US cloud for sensitive data' },
|
||||
{ v: '>70%', l: 'rejection in manufacturing specifically' },
|
||||
{ v: '83%', l: 'of SMEs cite data protection as the top AI barrier' },
|
||||
],
|
||||
},
|
||||
{ kicker: 'PATRIOT ACT + FISA 702', stat: 'No protection', desc: 'Even booking EU servers at AWS, Google or Microsoft offers no protection. US laws like FISA 702 and the Cloud Act apply extraterritorially. US authorities can access data regardless of server location.', cite: 'CJEU C-311/18 (Schrems II, 2020)',
|
||||
pulls: [
|
||||
{ v: '2020', l: 'CJEU invalidates EU-US Privacy Shield' },
|
||||
{ v: '0', l: 'legally safe paths for cloud data via US providers' },
|
||||
{ v: 'CLOUD Act', l: 'applies extraterritorially to US parent companies' },
|
||||
],
|
||||
},
|
||||
{ kicker: 'REGULATION TSUNAMI', stat: 'Unsustainable', desc: 'Since 2024, the AI Act, NIS2 and Cyber Resilience Act apply, on top of GDPR, Data Act, Machinery Regulation and Supply Chain Act. European companies bear compliance costs that US and Asian competitors do not face.', cite: 'VDMA Compliance Costs Manufacturing 2024',
|
||||
pulls: [
|
||||
{ v: '30 000+', l: 'DE companies directly hit by NIS2' },
|
||||
{ v: '€15-40k', l: 'per external pentest, once a year' },
|
||||
{ v: '€10-25k', l: 'per CE software risk assessment' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export function PrintProblemPage({ lang, pageNum, totalPages, versionName }: SlideBase) {
|
||||
const de = lang === 'de'
|
||||
const cards = de ? DE_PROBLEM_CARDS : EN_PROBLEM_CARDS
|
||||
return (
|
||||
<Page kicker="03" section={de ? 'DAS PROBLEM' : 'THE PROBLEM'} title={de ? 'Deutsche Unternehmen wollen KI, nicht um den Preis ihrer Datensouveränität.' : 'German companies want AI, not at the cost of their data sovereignty.'} subtitle={de ? 'Drei strukturelle Spannungen blockieren produzierende Unternehmen in der KI-Transformation.' : 'Three structural tensions are blocking manufacturing companies in the AI transformation.'} pageNum={pageNum} totalPages={totalPages} versionName={versionName} footnote={cards.map(c => c.cite).join(' · ')}>
|
||||
|
||||
<ThreeCol cols={cards.map((c, i) => (
|
||||
<div key={i} style={{ borderLeft: `2px solid ${COLORS.red600}`, paddingLeft: '5mm', height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ fontSize: '8pt', fontWeight: 700, color: COLORS.red700, textTransform: 'uppercase', letterSpacing: '0.12em', marginBottom: '3mm' }}>{c.kicker}</div>
|
||||
<div style={{ fontSize: '20pt', fontWeight: 800, color: COLORS.slate900, lineHeight: 1, letterSpacing: '-0.02em', marginBottom: '4mm' }}>{c.stat}</div>
|
||||
<div style={{ fontSize: '9pt', color: COLORS.slate700, lineHeight: 1.55, marginBottom: '4mm' }}>{c.desc}</div>
|
||||
{/* bottom stat block fills empty space and adds visual punch */}
|
||||
<div style={{ marginTop: 'auto', paddingTop: '4mm', borderTop: `1px solid ${COLORS.slate200}`, display: 'flex', flexDirection: 'column', gap: '3mm' }}>
|
||||
{c.pulls.map((p, j) => (
|
||||
<div key={j} style={{ display: 'flex', alignItems: 'baseline', gap: '4mm' }}>
|
||||
<div style={{ fontSize: '13pt', fontWeight: 800, color: COLORS.red700, fontVariantNumeric: 'tabular-nums', letterSpacing: '-0.02em', minWidth: '22mm' }}>{p.v}</div>
|
||||
<div style={{ fontSize: '7.5pt', color: COLORS.slate600, lineHeight: 1.4, flex: 1 }}>{p.l}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))} />
|
||||
|
||||
<div style={{ marginTop: '6mm', flexShrink: 0 }}>
|
||||
<Callout tone="caution" label={de ? 'Die Konsequenz' : 'The Consequence'}>
|
||||
{de
|
||||
? 'Produzierende Unternehmen brauchen eine KI-Lösung, die in Europa läuft, ihren Code schützt und Compliance automatisiert, ohne ihre Daten an US-Konzerne zu geben.'
|
||||
: 'Manufacturing companies need an AI solution that runs in Europe, protects their code and automates compliance, without giving their data to US corporations.'}
|
||||
</Callout>
|
||||
</div>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
/* ===== SOLUTION ===== */
|
||||
|
||||
const DE_PILLARS = [
|
||||
{ kicker: 'PILLAR 01', t: 'Kontinuierliche Code-Security', d: 'SAST, DAST, SBOM und Pentesting bei jeder Code-Änderung, nicht einmal im Jahr. Findings direkt als Tickets im Issue-Tracker deiner Wahl, mit Implementierungsvorschlägen.', stat: { v: '€15k+', l: 'Pentest-Kosten gespart / App / Jahr' },
|
||||
bullets: [
|
||||
'SAST + DAST + SBOM bei jedem Push (Semgrep, Gitleaks, Syft, Trivy)',
|
||||
'KI-Triage filtert False-Positives auf <2%',
|
||||
'LLM-basierter Auto-Fix in CI/CD direkt im Pull Request',
|
||||
'Autonomes KI-Pentesting mit Angriffsketten + Exploitability',
|
||||
'Integration in Jira, GitHub, GitLab, Azure DevOps',
|
||||
] },
|
||||
{ kicker: 'PILLAR 02', t: 'Compliance auf Autopilot', d: 'VVT, TOMs, DSFA, Löschfristen und CE-Risikobeurteilung werden automatisch generiert. Nach dem Audit: Abweichungen End-to-End mit Rollen, Stichtagen, Tickets und Eskalation an die GF.', stat: { v: '80%', l: 'Zeitersparnis bei Compliance-Prüfungen' },
|
||||
bullets: [
|
||||
'Auto-Generierung VVT (Art. 30 DSGVO) bei jeder Code-Änderung',
|
||||
'CE-Software-Risikobeurteilung auf Code-Basis (MaschVO 2023)',
|
||||
'Audit Manager: Tickets → Nachweise → GF-Eskalation bei SLA-Bruch',
|
||||
'Compliance LLM mit Quellenangabe — auditierbar zitierbar',
|
||||
'Tender Matching: RFQs in Stunden statt Wochen beantworten',
|
||||
] },
|
||||
{ kicker: 'PILLAR 03', t: 'Deutsche Cloud, volle Integration', d: 'BSI-zertifizierte Cloud in Deutschland (SysEleven, IONOS). Live-Support über Jitsi und Matrix. Keine US-SaaS im Source Code. Optional Mac Mini/Studio für absolute Privacy.', stat: { v: '100%', l: 'EU-Hosting, keine US-Anbieter' },
|
||||
bullets: [
|
||||
'BSI C5 zertifizierte Cloud-Hoster in DE und FR',
|
||||
'Self-Hosted Matrix (Chat) + Jitsi (Video) für Support-Calls',
|
||||
'Lokale LLM-Inferenz (Qwen3, DeepSeek) — air-gap-fähig',
|
||||
'Optional: Mac Mini/Studio on-premise für Kleinunternehmen',
|
||||
'Vault, Keycloak, OPA für Secrets, SSO, Policies',
|
||||
] },
|
||||
]
|
||||
const EN_PILLARS = [
|
||||
{ kicker: 'PILLAR 01', t: 'Continuous Code Security', d: 'SAST, DAST, SBOM and pentesting on every code change, not once a year. Findings land as tickets in your issue tracker, with implementation suggestions.', stat: { v: '€15k+', l: 'pentest costs saved / app / year' },
|
||||
bullets: [
|
||||
'SAST + DAST + SBOM on every push (Semgrep, Gitleaks, Syft, Trivy)',
|
||||
'AI triage lowers false positives to <2%',
|
||||
'LLM-based auto-fix in CI/CD directly inside the pull request',
|
||||
'Autonomous AI pentesting with attack chains + exploitability',
|
||||
'Integration with Jira, GitHub, GitLab, Azure DevOps',
|
||||
] },
|
||||
{ kicker: 'PILLAR 02', t: 'Compliance on Autopilot', d: 'RoPA, TOMs, DPIA, retention and CE risk assessment generated automatically. Post-audit: deviations end-to-end with roles, deadlines, tickets and escalation to management.', stat: { v: '80%', l: 'time saved on compliance checks' },
|
||||
bullets: [
|
||||
'Auto-generated RoPA (GDPR Art. 30) on every code change',
|
||||
'CE software risk assessment at code level (Machinery Reg. 2023)',
|
||||
'Audit Manager: tickets → evidence → mgmt escalation on SLA breach',
|
||||
'Compliance LLM with citations — audit-citable answers',
|
||||
'Tender Matching: answer RFQs in hours not weeks',
|
||||
] },
|
||||
{ kicker: 'PILLAR 03', t: 'German Cloud, Full Integration', d: 'BSI-certified cloud in Germany (SysEleven, IONOS). Live support via Jitsi and Matrix. No US SaaS in source code. Optional Mac Mini/Studio for absolute privacy.', stat: { v: '100%', l: 'EU hosting, no US providers' },
|
||||
bullets: [
|
||||
'BSI C5 certified cloud hosts in DE and FR',
|
||||
'Self-hosted Matrix (chat) + Jitsi (video) for support calls',
|
||||
'Local LLM inference (Qwen3, DeepSeek) — air-gap capable',
|
||||
'Optional: Mac Mini/Studio on-premise for micro businesses',
|
||||
'Vault, Keycloak, OPA for secrets, SSO, policies',
|
||||
] },
|
||||
]
|
||||
|
||||
export function PrintSolutionPage({ lang, pageNum, totalPages, versionName }: SlideBase) {
|
||||
const de = lang === 'de'
|
||||
const pillars = de ? DE_PILLARS : EN_PILLARS
|
||||
return (
|
||||
<Page kicker="04" section={de ? 'DIE LÖSUNG' : 'THE SOLUTION'} title={de ? 'Kontinuierliche Software-Compliance statt jährlicher Stichproben.' : 'Continuous software compliance instead of annual spot checks.'} subtitle={de ? 'Drei Säulen, Code-Security, Compliance-Automatisierung, EU-Souveränität, auf einer integrierten Plattform.' : 'Three pillars, code security, compliance automation, EU sovereignty, on one integrated platform.'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
|
||||
|
||||
<ThreeCol cols={pillars.map((p, i) => (
|
||||
<div key={i} style={{ borderLeft: `2px solid ${COLORS.indigo600}`, paddingLeft: '5mm', height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ fontSize: '8pt', fontWeight: 700, color: COLORS.indigo600, textTransform: 'uppercase', letterSpacing: '0.12em', marginBottom: '3mm' }}>{p.kicker}</div>
|
||||
<div style={{ fontSize: '14pt', fontWeight: 700, color: COLORS.slate900, lineHeight: 1.2, letterSpacing: '-0.005em', marginBottom: '4mm' }}>{p.t}</div>
|
||||
<div style={{ fontSize: '8.5pt', color: COLORS.slate700, lineHeight: 1.5, marginBottom: '4mm' }}>{p.d}</div>
|
||||
{/* Detail bullets fill the remaining vertical space */}
|
||||
<div style={{ flex: 1, minHeight: 0 }}>
|
||||
<Bullets dense items={p.bullets} />
|
||||
</div>
|
||||
<div style={{ marginTop: '4mm', paddingTop: '3mm', borderTop: `1px solid ${COLORS.slate200}` }}>
|
||||
<div style={{ fontSize: '22pt', fontWeight: 800, color: COLORS.emerald700, lineHeight: 1, fontVariantNumeric: 'tabular-nums' }}>{p.stat.v}</div>
|
||||
<div style={{ fontSize: '7.5pt', color: COLORS.slate500, textTransform: 'uppercase', letterSpacing: '0.08em', marginTop: '1.5mm', fontWeight: 600 }}>{p.stat.l}</div>
|
||||
</div>
|
||||
</div>
|
||||
))} />
|
||||
|
||||
<div style={{ marginTop: '6mm', flexShrink: 0 }}>
|
||||
<Callout tone="accent" label={de ? 'Wie es zusammenkommt' : 'How it comes together'}>
|
||||
{de
|
||||
? 'BreakPilot ist die einzige Plattform, die kontinuierliche Code-Security, automatisierte Compliance-Dokumentation und CE-Risikobeurteilung in EU-souveräner Infrastruktur vereint, eine Plattform, ein Audit, eine Rechnung.'
|
||||
: 'BreakPilot is the only platform that unifies continuous code security, automated compliance documentation and CE risk assessment on EU-sovereign infrastructure, one platform, one audit, one bill.'}
|
||||
</Callout>
|
||||
</div>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,409 @@
|
||||
import React from 'react'
|
||||
|
||||
const INDIGO = '#6366f1'
|
||||
const INDIGO_LIGHT = '#eef2ff'
|
||||
const TEXT_DARK = '#1e1b4b'
|
||||
const TEXT_MED = '#374151'
|
||||
const TEXT_LIGHT = '#6b7280'
|
||||
const BORDER = '#e0e7ff'
|
||||
/* ===== DESIGN TOKENS ===== */
|
||||
|
||||
/**
|
||||
* Adapted from Claude Design tokens (light theme).
|
||||
* Primary accent = violet (#7c3aed). The names `indigo*` are kept as aliases
|
||||
* so existing slide files don't need to be touched — the *values* now resolve
|
||||
* to the violet palette. New code can use the explicit `violet*` names.
|
||||
*/
|
||||
export const COLORS = {
|
||||
// Body text — deep purple-tinted instead of pure slate
|
||||
slate900: '#1a0f34',
|
||||
slate800: '#2a1f4a',
|
||||
slate700: 'rgba(26,15,52,.88)',
|
||||
slate600: 'rgba(26,15,52,.72)',
|
||||
slate500: 'rgba(26,15,52,.60)',
|
||||
slate400: 'rgba(26,15,52,.46)',
|
||||
slate300: 'rgba(26,15,52,.28)',
|
||||
slate200: 'rgba(26,15,52,.14)',
|
||||
slate100: 'rgba(26,15,52,.06)',
|
||||
slate50: 'rgba(26,15,52,.03)',
|
||||
// Violet palette (new primary accent)
|
||||
violet900: '#3b0e7a',
|
||||
violet800: '#5b21b6',
|
||||
violet700: '#6d28d9',
|
||||
violet600: '#7c3aed',
|
||||
violet500: '#8b5cf6',
|
||||
violet400: '#a78bfa',
|
||||
violet300: '#c4b5fd',
|
||||
violet200: '#ddd6fe',
|
||||
violet100: '#ede9fe',
|
||||
violet50: '#f5f3ff',
|
||||
// Legacy `indigo*` aliases — kept so existing slide code compiles unchanged
|
||||
indigo700: '#6d28d9',
|
||||
indigo600: '#7c3aed',
|
||||
indigo500: '#8b5cf6',
|
||||
indigo50: '#f5f3ff',
|
||||
// Functional accents
|
||||
emerald700: '#047857',
|
||||
emerald600: '#059669',
|
||||
emerald50: '#ecfdf5',
|
||||
red700: '#b91c1c',
|
||||
red600: '#dc2626',
|
||||
red50: '#fef2f2',
|
||||
amber700: '#b45309',
|
||||
amber600: '#d97706',
|
||||
amber50: '#fffbeb',
|
||||
// Legacy aliases used by some callers
|
||||
dark: '#1a0f34',
|
||||
med: 'rgba(26,15,52,.88)',
|
||||
light: 'rgba(26,15,52,.60)',
|
||||
border: 'rgba(26,15,52,.14)',
|
||||
indigo: '#7c3aed',
|
||||
indigoLight: '#f5f3ff',
|
||||
}
|
||||
|
||||
const FONT = "'Inter', 'Plus Jakarta Sans', system-ui, -apple-system, sans-serif"
|
||||
const MONO_FONT = "'JetBrains Mono', ui-monospace, Menlo, Consolas, monospace"
|
||||
|
||||
/**
|
||||
* Brand wordmark: `Compl` rendered in the inherited text color, `AI` in violet.
|
||||
* No slashes, no separators — matches the BreakPilot/ComplAI brand guideline.
|
||||
* Use this anywhere the product name appears in body or display text.
|
||||
*
|
||||
* Use the optional `prefix` to prepend "BreakPilot " in the same style.
|
||||
*/
|
||||
export function ComplAI({ prefix = false }: { prefix?: boolean } = {}) {
|
||||
return (
|
||||
<>
|
||||
{prefix && <>BreakPilot </>}
|
||||
Compl<span style={{ color: COLORS.violet600 }}>AI</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/* ===== PAGE WRAPPER ===== */
|
||||
|
||||
interface PageProps {
|
||||
kicker: string // "03"
|
||||
section: string // "DAS PROBLEM"
|
||||
title: React.ReactNode // string or JSX (e.g. <ComplAI prefix /> usage)
|
||||
subtitle?: React.ReactNode
|
||||
pageNum: number
|
||||
totalPages: number
|
||||
versionName: string
|
||||
children: React.ReactNode
|
||||
footnote?: React.ReactNode // optional footnote line above footer
|
||||
}
|
||||
|
||||
export function Page({ kicker, section, title, subtitle, pageNum, totalPages, versionName, children, footnote }: PageProps) {
|
||||
return (
|
||||
<div className="print-page-break">
|
||||
<div className="print-page print-page-bg" style={{
|
||||
width: '297mm',
|
||||
height: '210mm',
|
||||
color: COLORS.slate900,
|
||||
fontFamily: FONT,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
boxSizing: 'border-box',
|
||||
padding: '11mm 14mm 8mm 14mm',
|
||||
// screen-only decoration
|
||||
margin: '0 auto 24px',
|
||||
boxShadow: '0 4px 24px rgba(59,26,122,0.10)',
|
||||
WebkitPrintColorAdjust: 'exact',
|
||||
printColorAdjust: 'exact',
|
||||
}}>
|
||||
{/* TITLE BLOCK — left-rule preserved */}
|
||||
<div style={{ display: 'flex', alignItems: 'stretch', gap: '7mm', marginBottom: '5mm', flexShrink: 0 }}>
|
||||
<div style={{ width: '3px', background: COLORS.violet600, WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact', alignSelf: 'stretch', minHeight: '14mm' }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: '4mm', marginBottom: '2mm' }}>
|
||||
<span className="print-mono" style={{ fontFamily: MONO_FONT, fontSize: '7.5pt', fontWeight: 700, letterSpacing: '0.18em', color: COLORS.violet600, textTransform: 'uppercase' }}>
|
||||
{kicker} · {section}
|
||||
</span>
|
||||
<span style={{ flex: 1, height: '1px', background: `linear-gradient(90deg, ${COLORS.violet400}, transparent 80%)`, alignSelf: 'center', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }} />
|
||||
<span className="print-mono" style={{ fontFamily: MONO_FONT, fontSize: '7pt', color: COLORS.slate500, fontWeight: 500, letterSpacing: '0.06em' }}>BreakPilot · ComplAI</span>
|
||||
</div>
|
||||
<h1 style={{ fontSize: '22pt', fontWeight: 800, color: COLORS.slate900, margin: 0, lineHeight: 1.1, letterSpacing: '-0.015em' }}>
|
||||
{title}
|
||||
</h1>
|
||||
{subtitle && (
|
||||
<p style={{ fontSize: '10pt', color: COLORS.slate600, margin: '2mm 0 0', fontWeight: 400, lineHeight: 1.45, maxWidth: '210mm' }}>
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
{/* Subtle violet accent gradient bar — echo of the Claude Design feel */}
|
||||
<div style={{ marginTop: '3mm', height: '2px', width: '100%', background: `linear-gradient(90deg, ${COLORS.violet700} 0%, ${COLORS.violet400} 50%, ${COLORS.violet700} 100%)`, opacity: 0.85, WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CONTENT */}
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* FOOTNOTE (optional) */}
|
||||
{footnote && (
|
||||
<div style={{ flexShrink: 0, marginTop: '3mm', paddingTop: '2mm', borderTop: `1px solid ${COLORS.slate200}`, fontSize: '7pt', color: COLORS.slate500, lineHeight: 1.4 }}>
|
||||
{footnote}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* FOOTER — JetBrains Mono caps to match Claude Design */}
|
||||
<div className="print-mono" style={{ fontFamily: MONO_FONT, flexShrink: 0, marginTop: '3mm', paddingTop: '2mm', borderTop: `1px solid ${COLORS.slate200}`, display: 'flex', alignItems: 'center', justifyContent: 'space-between', fontSize: '7pt', color: COLORS.slate500, letterSpacing: '0.16em', textTransform: 'uppercase', fontWeight: 700 }}>
|
||||
<span>BreakPilot · ComplAI</span>
|
||||
<span style={{ color: COLORS.violet600 }}>{versionName}</span>
|
||||
<span style={{ fontVariantNumeric: 'tabular-nums' }}>{String(pageNum).padStart(2, '0')} / {String(totalPages).padStart(2, '0')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ===== KPI ROW ===== */
|
||||
|
||||
interface KpiItem { n: string; label: string; tone?: 'default' | 'positive' | 'negative' | 'accent' }
|
||||
|
||||
export function KpiRow({ items, align = 'left' }: { items: KpiItem[]; align?: 'left' | 'center' }) {
|
||||
return (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: `repeat(${items.length}, 1fr)`, gap: '4mm', borderTop: `1px solid ${COLORS.slate200}`, borderBottom: `1px solid ${COLORS.slate200}`, padding: '4mm 0', margin: '2mm 0' }}>
|
||||
{items.map((it, i) => {
|
||||
const color = it.tone === 'positive' ? COLORS.emerald700
|
||||
: it.tone === 'negative' ? COLORS.red700
|
||||
: it.tone === 'accent' ? COLORS.indigo600
|
||||
: COLORS.slate900
|
||||
return (
|
||||
<div key={i} style={{ textAlign: align }}>
|
||||
<div style={{ fontSize: '24pt', fontWeight: 800, color, lineHeight: 1, letterSpacing: '-0.02em', fontVariantNumeric: 'tabular-nums' }}>
|
||||
{it.n}
|
||||
</div>
|
||||
<div style={{ marginTop: '2mm', fontSize: '7.5pt', fontWeight: 600, color: COLORS.slate500, textTransform: 'uppercase', letterSpacing: '0.08em', lineHeight: 1.3 }}>
|
||||
{it.label}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ===== COLUMNS ===== */
|
||||
|
||||
export function TwoCol({ left, right, ratio = '1:1', gap = '8mm' }: { left: React.ReactNode; right: React.ReactNode; ratio?: '1:1' | '1:1.5' | '1.5:1' | '1:2' | '2:1'; gap?: string }) {
|
||||
const [l, r] = ratio.split(':').map(Number)
|
||||
return (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: `${l}fr ${r}fr`, gap, flex: 1, minHeight: 0 }}>
|
||||
<div style={{ minWidth: 0, display: 'flex', flexDirection: 'column' }}>{left}</div>
|
||||
<div style={{ minWidth: 0, display: 'flex', flexDirection: 'column' }}>{right}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ThreeCol({ cols, gap = '6mm', equalHeight = true }: { cols: React.ReactNode[]; gap?: string; equalHeight?: boolean }) {
|
||||
return (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap, ...(equalHeight ? { flex: 1, minHeight: 0 } : {}) }}>
|
||||
{cols.map((c, i) => <div key={i} style={{ minWidth: 0, display: 'flex', flexDirection: 'column' }}>{c}</div>)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function FourCol({ cols, gap = '4mm' }: { cols: React.ReactNode[]; gap?: string }) {
|
||||
return (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap }}>
|
||||
{cols.map((c, i) => <div key={i} style={{ minWidth: 0 }}>{c}</div>)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ===== PANEL (left-rule narrative card) ===== */
|
||||
|
||||
interface PanelProps {
|
||||
label?: string
|
||||
title?: string
|
||||
tone?: 'neutral' | 'positive' | 'negative' | 'caution' | 'accent'
|
||||
children: React.ReactNode
|
||||
dense?: boolean
|
||||
}
|
||||
|
||||
export function Panel({ label, title, tone = 'neutral', children, dense }: PanelProps) {
|
||||
const color = tone === 'positive' ? COLORS.emerald700
|
||||
: tone === 'negative' ? COLORS.red700
|
||||
: tone === 'caution' ? COLORS.amber700
|
||||
: tone === 'accent' ? COLORS.indigo600
|
||||
: COLORS.slate600
|
||||
return (
|
||||
<div style={{ borderLeft: `2px solid ${color}`, paddingLeft: dense ? '4mm' : '5mm', paddingRight: '2mm', flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
||||
{label && (
|
||||
<div style={{ fontSize: '7.5pt', fontWeight: 700, color, textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '2mm' }}>
|
||||
{label}
|
||||
</div>
|
||||
)}
|
||||
{title && (
|
||||
<div style={{ fontSize: '12pt', fontWeight: 700, color: COLORS.slate900, marginBottom: '3mm', lineHeight: 1.25, letterSpacing: '-0.005em' }}>
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ flex: 1, minHeight: 0, fontSize: '9pt', color: COLORS.slate700, lineHeight: 1.55 }}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ===== BULLETS ===== */
|
||||
|
||||
interface BulletsProps { items: (string | React.ReactNode)[]; dense?: boolean; tone?: 'neutral' | 'positive' | 'negative' | 'accent' }
|
||||
|
||||
export function Bullets({ items, dense, tone = 'neutral' }: BulletsProps) {
|
||||
const dotColor = tone === 'positive' ? COLORS.emerald600
|
||||
: tone === 'negative' ? COLORS.red600
|
||||
: tone === 'accent' ? COLORS.indigo600
|
||||
: COLORS.slate500
|
||||
/**
|
||||
* Em-dash marker uses display:flex to keep the rule vertically centered on
|
||||
* the first line of text — previously the `top: 4pt` absolute positioning
|
||||
* drifted relative to font size (looked off-center on the rendered PDF).
|
||||
* The marker now sits in a fixed-width column at the line height of the text.
|
||||
*/
|
||||
const fontSize = dense ? '8.5pt' : '9pt'
|
||||
const lineH = 1.5
|
||||
return (
|
||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0 }}>
|
||||
{items.map((item, i) => (
|
||||
<li key={i} style={{ display: 'flex', alignItems: 'flex-start', gap: '3mm', marginBottom: dense ? '1.5mm' : '2.5mm', fontSize, color: COLORS.slate700, lineHeight: lineH }}>
|
||||
{/* The dash sits on the first line: line-height pad above + 0.5pt rule */}
|
||||
<span style={{ flexShrink: 0, width: '3mm', display: 'inline-flex', alignItems: 'center', height: `${parseFloat(fontSize) * lineH}pt` }}>
|
||||
<span style={{ width: '100%', height: '0.5pt', background: dotColor, WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }} />
|
||||
</span>
|
||||
<span style={{ flex: 1, minWidth: 0 }}>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
/* ===== DATA TABLE ===== */
|
||||
|
||||
interface TableCol {
|
||||
header: string
|
||||
width?: string
|
||||
align?: 'left' | 'right' | 'center'
|
||||
numeric?: boolean
|
||||
}
|
||||
|
||||
interface DataTableProps {
|
||||
cols: TableCol[]
|
||||
rows: (string | number | React.ReactNode)[][]
|
||||
dense?: boolean
|
||||
zebra?: boolean
|
||||
highlightFirstCol?: boolean
|
||||
}
|
||||
|
||||
export function DataTable({ cols, rows, dense, zebra = true, highlightFirstCol }: DataTableProps) {
|
||||
const fontSize = dense ? '7.5pt' : '8.5pt'
|
||||
const padY = dense ? '1.5mm' : '2mm'
|
||||
return (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize, color: COLORS.slate800, fontVariantNumeric: 'tabular-nums' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
{cols.map((c, i) => (
|
||||
<th key={i} style={{
|
||||
background: COLORS.indigo50,
|
||||
color: COLORS.indigo700,
|
||||
fontWeight: 700,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.08em',
|
||||
fontSize: dense ? '6.5pt' : '7pt',
|
||||
padding: `${padY} 2.5mm`,
|
||||
textAlign: c.align || (c.numeric ? 'right' : 'left'),
|
||||
width: c.width,
|
||||
borderBottom: `1px solid ${COLORS.slate200}`,
|
||||
WebkitPrintColorAdjust: 'exact',
|
||||
printColorAdjust: 'exact',
|
||||
}}>
|
||||
{c.header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, ri) => (
|
||||
<tr key={ri} style={{ background: zebra && ri % 2 === 1 ? COLORS.slate50 : 'transparent', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
{row.map((cell, ci) => (
|
||||
<td key={ci} style={{
|
||||
padding: `${padY} 2.5mm`,
|
||||
textAlign: cols[ci]?.align || (cols[ci]?.numeric ? 'right' : 'left'),
|
||||
borderBottom: `1px solid ${COLORS.slate100}`,
|
||||
verticalAlign: 'top',
|
||||
lineHeight: 1.4,
|
||||
fontWeight: highlightFirstCol && ci === 0 ? 600 : 400,
|
||||
color: highlightFirstCol && ci === 0 ? COLORS.slate900 : COLORS.slate700,
|
||||
whiteSpace: cols[ci]?.numeric ? 'nowrap' : 'normal',
|
||||
}}>
|
||||
{cell}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)
|
||||
}
|
||||
|
||||
/* ===== FEATURE MATRIX (●/○/—) ===== */
|
||||
|
||||
export type Glyph = true | false | 'partial'
|
||||
|
||||
export function MatrixGlyph({ v, isUSP }: { v: Glyph; isUSP?: boolean }) {
|
||||
if (v === true) return <span style={{ color: isUSP ? COLORS.indigo600 : COLORS.slate800, fontWeight: 700 }}>●</span>
|
||||
if (v === 'partial') return <span style={{ color: COLORS.amber700 }}>◑</span>
|
||||
return <span style={{ color: COLORS.slate300 }}>—</span>
|
||||
}
|
||||
|
||||
/* ===== CALLOUT ===== */
|
||||
|
||||
interface CalloutProps {
|
||||
tone?: 'neutral' | 'positive' | 'negative' | 'caution' | 'accent'
|
||||
label?: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function Callout({ tone = 'neutral', label, children }: CalloutProps) {
|
||||
const color = tone === 'positive' ? COLORS.emerald700
|
||||
: tone === 'negative' ? COLORS.red700
|
||||
: tone === 'caution' ? COLORS.amber700
|
||||
: tone === 'accent' ? COLORS.indigo600
|
||||
: COLORS.slate600
|
||||
const bg = tone === 'positive' ? COLORS.emerald50
|
||||
: tone === 'negative' ? COLORS.red50
|
||||
: tone === 'caution' ? COLORS.amber50
|
||||
: tone === 'accent' ? COLORS.indigo50
|
||||
: COLORS.slate50
|
||||
return (
|
||||
<div style={{ borderLeft: `3px solid ${color}`, background: bg, padding: '3mm 4mm', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
{label && (
|
||||
<div style={{ fontSize: '7.5pt', fontWeight: 700, color, textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '1.5mm' }}>{label}</div>
|
||||
)}
|
||||
<div style={{ fontSize: '9pt', color: COLORS.slate800, lineHeight: 1.5 }}>{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ===== DIVIDER ===== */
|
||||
|
||||
export function Divider({ space = '4mm' }: { space?: string }) {
|
||||
return <div style={{ height: '1px', background: COLORS.slate200, margin: `${space} 0` }} />
|
||||
}
|
||||
|
||||
/* ===== STAT INLINE (label: value) ===== */
|
||||
|
||||
export function StatLine({ label, value, tone = 'neutral' }: { label: string; value: string; tone?: 'neutral' | 'positive' | 'negative' | 'accent' }) {
|
||||
const color = tone === 'positive' ? COLORS.emerald700
|
||||
: tone === 'negative' ? COLORS.red700
|
||||
: tone === 'accent' ? COLORS.indigo600
|
||||
: COLORS.slate900
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', padding: '1.5mm 0', borderBottom: `1px solid ${COLORS.slate100}`, fontSize: '8.5pt' }}>
|
||||
<span style={{ color: COLORS.slate600 }}>{label}</span>
|
||||
<span style={{ fontWeight: 700, color, fontVariantNumeric: 'tabular-nums' }}>{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ===== LEGACY EXPORTS (preserved for any callers not yet migrated) ===== */
|
||||
|
||||
interface PrintPageProps {
|
||||
title: string
|
||||
@@ -16,83 +414,26 @@ interface PrintPageProps {
|
||||
}
|
||||
|
||||
export function PrintPage({ title, pageNum, totalPages, versionName, children }: PrintPageProps) {
|
||||
// Legacy wrapper: maps to the new Page primitive without title/subtitle structure
|
||||
return (
|
||||
<div className="print-page-break">
|
||||
<div className="print-page" style={{
|
||||
width: '297mm',
|
||||
minHeight: '210mm',
|
||||
height: '210mm',
|
||||
backgroundColor: '#ffffff',
|
||||
color: TEXT_DARK,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
fontFamily: 'system-ui, -apple-system, "Segoe UI", sans-serif',
|
||||
boxSizing: 'border-box',
|
||||
// screen-only decoration
|
||||
margin: '0 auto 32px',
|
||||
boxShadow: '0 4px 24px rgba(0,0,0,0.12)',
|
||||
}}>
|
||||
{/* Header bar */}
|
||||
<div style={{
|
||||
height: '32px',
|
||||
backgroundColor: INDIGO,
|
||||
WebkitPrintColorAdjust: 'exact',
|
||||
printColorAdjust: 'exact',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '0 18px',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<span style={{ color: '#fff', fontWeight: 700, fontSize: '12px', letterSpacing: '0.02em' }}>BreakPilot</span>
|
||||
<span style={{ color: 'rgba(255,255,255,0.85)', fontSize: '11px' }}>{title}</span>
|
||||
</div>
|
||||
|
||||
{/* Content area — must stretch to fill all remaining height */}
|
||||
<div style={{
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
padding: '16px 22px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: 0,
|
||||
}}>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Footer bar */}
|
||||
<div style={{
|
||||
height: '24px',
|
||||
borderTop: `1px solid ${BORDER}`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '0 18px',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<span style={{ fontSize: '9px', color: TEXT_LIGHT }}>{versionName}</span>
|
||||
<span style={{ fontSize: '9px', color: INDIGO, fontWeight: 600, letterSpacing: '0.08em', textTransform: 'uppercase' }}>CONFIDENTIAL</span>
|
||||
<span style={{ fontSize: '9px', color: TEXT_LIGHT }}>{pageNum} / {totalPages}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Page
|
||||
kicker=""
|
||||
section={title.toUpperCase()}
|
||||
title={title}
|
||||
pageNum={pageNum}
|
||||
totalPages={totalPages}
|
||||
versionName={versionName}
|
||||
>
|
||||
{children}
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
interface SectionTitleProps { children: React.ReactNode; subtitle?: string }
|
||||
|
||||
export function SectionTitle({ children, subtitle }: SectionTitleProps) {
|
||||
export function SectionTitle({ children, subtitle }: { children: React.ReactNode; subtitle?: string }) {
|
||||
return (
|
||||
<div style={{ marginBottom: '12px', flexShrink: 0 }}>
|
||||
<h2 style={{ fontSize: '19px', fontWeight: 700, color: TEXT_DARK, borderLeft: `3px solid ${INDIGO}`, paddingLeft: '10px', margin: 0, lineHeight: 1.3 }}>
|
||||
{children}
|
||||
</h2>
|
||||
{subtitle && (
|
||||
<p style={{ fontSize: '11px', color: TEXT_LIGHT, marginTop: '4px', marginLeft: '13px', marginBottom: 0 }}>
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
<div style={{ marginBottom: '4mm' }}>
|
||||
<h2 style={{ fontSize: '14pt', fontWeight: 700, color: COLORS.slate900, margin: 0, lineHeight: 1.2 }}>{children}</h2>
|
||||
{subtitle && <p style={{ fontSize: '9pt', color: COLORS.slate600, marginTop: '1.5mm', marginBottom: 0 }}>{subtitle}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -104,50 +445,14 @@ interface TableProps {
|
||||
}
|
||||
|
||||
export function PrintTable({ headers, rows, colWidths }: TableProps) {
|
||||
return (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '10px' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
{headers.map((h, i) => (
|
||||
<th key={i} style={{
|
||||
backgroundColor: INDIGO_LIGHT,
|
||||
color: TEXT_DARK,
|
||||
fontWeight: 700,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.04em',
|
||||
fontSize: '9px',
|
||||
padding: '6px 9px',
|
||||
textAlign: 'left',
|
||||
width: colWidths?.[i],
|
||||
WebkitPrintColorAdjust: 'exact',
|
||||
printColorAdjust: 'exact',
|
||||
}}>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, ri) => (
|
||||
<tr key={ri} style={{ backgroundColor: ri % 2 === 0 ? '#ffffff' : '#fafafa' }}>
|
||||
{row.map((cell, ci) => (
|
||||
<td key={ci} style={{ padding: '6px 9px', color: TEXT_MED, borderBottom: `1px solid ${BORDER}`, verticalAlign: 'top', lineHeight: 1.5 }}>
|
||||
{cell}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)
|
||||
const cols = headers.map((h, i) => ({ header: h, width: colWidths?.[i] }))
|
||||
return <DataTable cols={cols} rows={rows} />
|
||||
}
|
||||
|
||||
export function Badge({ children, color = INDIGO }: { children: React.ReactNode; color?: string }) {
|
||||
export function Badge({ children, color = COLORS.indigo600 }: { children: React.ReactNode; color?: string }) {
|
||||
return (
|
||||
<span style={{ display: 'inline-block', padding: '2px 8px', borderRadius: '99px', backgroundColor: `${color}22`, color, fontSize: '9px', fontWeight: 600 }}>
|
||||
<span style={{ display: 'inline-block', padding: '0.5mm 2mm', borderRadius: '2pt', background: `${color}1f`, color, fontSize: '7.5pt', fontWeight: 600, letterSpacing: '0.02em', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export const COLORS = { indigo: INDIGO, indigoLight: INDIGO_LIGHT, dark: TEXT_DARK, med: TEXT_MED, light: TEXT_LIGHT, border: BORDER }
|
||||
|
||||
@@ -0,0 +1,497 @@
|
||||
import { Language, PitchMarket, PitchTeamMember, PitchMilestone, PitchFunding } from '@/lib/types'
|
||||
import { Page, Callout, COLORS, StatLine } from './PrintLayout'
|
||||
import { ComparisonBars, DonutChart } from './PrintCharts'
|
||||
import {
|
||||
Briefcase, RefreshCw, Handshake, Scale, Lightbulb,
|
||||
Code, TrendingUp, CreditCard, ShieldCheck, Cpu,
|
||||
} from 'lucide-react'
|
||||
|
||||
interface SlideBase { lang: Language; pageNum: number; totalPages: number; versionName: string }
|
||||
|
||||
function fmtEur(v: number, de: boolean) {
|
||||
if (v >= 1e9) return de ? `${(v / 1e9).toFixed(1).replace('.', ',')} Mrd. €` : `€${(v / 1e9).toFixed(1)}B`
|
||||
if (v >= 1e6) return de ? `${(v / 1e6).toFixed(0)} Mio. €` : `€${(v / 1e6).toFixed(0)}M`
|
||||
if (v >= 1e3) return de ? `${(v / 1e3).toFixed(0)}k €` : `€${(v / 1e3).toFixed(0)}k`
|
||||
return de ? `${v} €` : `€${v}`
|
||||
}
|
||||
|
||||
/* ===== MARKET ===== */
|
||||
|
||||
export function PrintMarketPage({ market, lang, pageNum, totalPages, versionName }: SlideBase & { market: PitchMarket[] }) {
|
||||
const de = lang === 'de'
|
||||
const tam = market.find(m => m.market_segment === 'TAM')
|
||||
const sam = market.find(m => m.market_segment === 'SAM')
|
||||
const som = market.find(m => m.market_segment === 'SOM')
|
||||
|
||||
// Fallbacks if data missing
|
||||
const tamValue = tam?.value_eur ?? 340_000_000_000
|
||||
const samValue = sam?.value_eur ?? 48_000_000_000
|
||||
const somValue = som?.value_eur ?? 2_100_000_000
|
||||
const cards = [
|
||||
{ key: 'TAM', value: fmtEur(tamValue, de), growth: tam?.growth_rate_pct ?? 14, accent: 'violet' as const,
|
||||
desc: de ? 'Globaler Compliance- und GRC-Markt, alle Branchen, alle Größen.' : 'Global compliance and GRC market, all industries, all sizes.' },
|
||||
{ key: 'SAM', value: fmtEur(samValue, de), growth: sam?.growth_rate_pct ?? 18, accent: 'violet-soft' as const,
|
||||
desc: de ? 'DACH + EU: regulierte Branchen, KMU und Enterprise.' : 'DACH + EU: regulated industries, SMB and enterprise.' },
|
||||
{ key: 'SOM', value: fmtEur(somValue, de), growth: som?.growth_rate_pct ?? 25, accent: 'amber' as const, core: true,
|
||||
desc: de ? 'Anlagen- und Maschinenbau DACH, unser Kernsegment.' : 'Machine and plant manufacturing DACH, our core segment.' },
|
||||
]
|
||||
|
||||
// SVG nested-circles geometry — viewBox unitless, render up to ~130mm wide
|
||||
const CX = 65, CY = 65, R_TAM = 60, R_SAM = 36, R_SOM = 14
|
||||
|
||||
return (
|
||||
<Page kicker="09" section={de ? 'MARKT' : 'MARKET'} title={de ? 'Compliance & Code-Security für produzierende Unternehmen.' : 'Compliance & code security for manufacturing companies.'} subtitle={de ? 'Validierter Markt: Top-10 Compliance-Anbieter erwirtschaften >$1,1 Mrd. ARR. Kein Anbieter bedient den Maschinenbau spezifisch.' : 'Validated market: top-10 compliance vendors generate >$1.1B ARR. No vendor specifically serves manufacturing.'} pageNum={pageNum} totalPages={totalPages} versionName={versionName} footnote="Sacra · Bitkom Cloud Monitor 2024 · DIHK 2024 · VDMA · Statista">
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1.05fr', gap: '10mm', flex: 1, minHeight: 0 }}>
|
||||
{/* LEFT: nested-circles diagram */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||||
<div style={{ fontSize: '8pt', fontWeight: 700, color: COLORS.slate500, textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '3mm' }}>{de ? 'Marktdimensionierung' : 'Market sizing'}</div>
|
||||
<div style={{ flex: 1, minHeight: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<svg viewBox="0 0 130 130" style={{ width: '100%', maxWidth: '130mm', height: 'auto', display: 'block' }} aria-hidden>
|
||||
<circle cx={CX} cy={CY} r={R_TAM} fill={COLORS.violet50} stroke={COLORS.violet400} strokeWidth="0.6" />
|
||||
<circle cx={CX} cy={CY} r={R_SAM} fill={COLORS.violet100} stroke={COLORS.violet500} strokeWidth="0.7" />
|
||||
<circle cx={CX} cy={CY} r={R_SOM} fill={COLORS.amber50} stroke={COLORS.amber600} strokeWidth="0.9" />
|
||||
<text x={CX} y={CY - R_TAM + 7} textAnchor="middle" fontSize="3.2" fontFamily="'JetBrains Mono', ui-monospace, monospace" fontWeight={700} letterSpacing="0.18em" fill={COLORS.violet700}>TAM</text>
|
||||
<text x={CX} y={CY - R_TAM + 11.5} textAnchor="middle" fontSize="3.6" fontWeight={700} fill={COLORS.slate700}>{fmtEur(tamValue, de)}</text>
|
||||
<text x={CX} y={CY - R_SAM + 6} textAnchor="middle" fontSize="3" fontFamily="'JetBrains Mono', ui-monospace, monospace" fontWeight={700} letterSpacing="0.18em" fill={COLORS.violet800}>SAM</text>
|
||||
<text x={CX} y={CY - R_SAM + 10} textAnchor="middle" fontSize="3.4" fontWeight={700} fill={COLORS.slate800}>{fmtEur(samValue, de)}</text>
|
||||
<text x={CX} y={CY - 1.2} textAnchor="middle" fontSize="2.8" fontFamily="'JetBrains Mono', ui-monospace, monospace" fontWeight={700} letterSpacing="0.18em" fill={COLORS.amber700}>SOM</text>
|
||||
<text x={CX} y={CY + 3} textAnchor="middle" fontSize="3.4" fontWeight={800} fill={COLORS.slate900}>{fmtEur(somValue, de)}</text>
|
||||
</svg>
|
||||
</div>
|
||||
<div style={{ marginTop: '3mm', fontSize: '7.5pt', color: COLORS.slate500, lineHeight: 1.4, textAlign: 'center' }}>
|
||||
{de ? 'Verschachtelte Marktanteile (TAM ⊃ SAM ⊃ SOM)' : 'Nested market shares (TAM ⊃ SAM ⊃ SOM)'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RIGHT: stacked info cards + callout */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', minHeight: 0, gap: '3.5mm' }}>
|
||||
{cards.map((c) => {
|
||||
const isAmber = c.accent === 'amber'
|
||||
const isSoft = c.accent === 'violet-soft'
|
||||
const stroke = isAmber ? COLORS.amber600 : isSoft ? COLORS.violet500 : COLORS.violet700
|
||||
const bg = isAmber ? COLORS.amber50 : isSoft ? COLORS.violet50 : 'transparent'
|
||||
const kickerColor = isAmber ? COLORS.amber700 : COLORS.violet700
|
||||
const valueColor = isAmber ? COLORS.amber700 : COLORS.slate900
|
||||
return (
|
||||
<div key={c.key} style={{ borderLeft: `3px solid ${stroke}`, background: bg, padding: '3mm 4mm', display: 'flex', flexDirection: 'column', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: '3mm' }}>
|
||||
<span style={{ fontFamily: "'JetBrains Mono', ui-monospace, monospace", fontSize: '7.5pt', fontWeight: 700, letterSpacing: '0.18em', color: kickerColor, textTransform: 'uppercase' }}>{c.key}</span>
|
||||
{c.core && (<span style={{ fontSize: '7pt', fontWeight: 700, color: COLORS.amber700, background: '#fff', border: `1px solid ${COLORS.amber600}`, padding: '0.5mm 1.5mm', letterSpacing: '0.06em', textTransform: 'uppercase' }}>{de ? '← unser Kernmarkt' : '← our core market'}</span>)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: '3mm', marginTop: '1mm' }}>
|
||||
<div style={{ fontSize: '22pt', fontWeight: 800, color: valueColor, lineHeight: 1, letterSpacing: '-0.02em', fontVariantNumeric: 'tabular-nums' }}>{c.value}</div>
|
||||
<div style={{ fontSize: '7.5pt', color: COLORS.slate500, fontWeight: 600, fontVariantNumeric: 'tabular-nums' }}>+{c.growth}% CAGR</div>
|
||||
</div>
|
||||
<div style={{ marginTop: '1.5mm', fontSize: '8pt', color: COLORS.slate700, lineHeight: 1.4 }}>{c.desc}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
<div style={{ marginTop: 'auto' }}>
|
||||
<Callout tone="accent" label={de ? 'Warum Maschinenbau zuerst' : 'Why manufacturing first'}>
|
||||
{de
|
||||
? 'Höchste Regulierungsdichte (DSGVO + AI Act + CRA + Maschinen-VO + ProdSG + LkSG) bei gleichzeitig kleinem Compliance-Team. Klare Schmerzpunkte. Bekannte Vertriebskanäle (VDMA, IHK, Messen).'
|
||||
: 'Highest regulation density (GDPR + AI Act + CRA + Machinery Reg. + ProdSG + LkSG) with simultaneously small compliance teams. Clear pain points. Known sales channels (VDMA, Chamber of Commerce, fairs).'}
|
||||
</Callout>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
/* ===== MILESTONES / TRACTION ===== */
|
||||
|
||||
const MS_DE = [
|
||||
{ d: 'Okt 2025', t: 'Gründerzuschuss & IHK Konstanz', s: 'done' },
|
||||
{ d: '11 Nov 2025', t: 'DPMA-Markenanmeldung BreakPilot', s: 'done' },
|
||||
{ d: '21 Nov 2025', t: 'Domain-Portfolio (.com, .de, .ai + Typo)', s: 'done' },
|
||||
{ d: 'Jan 2026', t: 'Plattform-Entwicklung gestartet (500K+ LoC)', s: 'done' },
|
||||
{ d: '27 Mär 2026', t: 'DPMA-Markeneintragung BreakPilot', s: 'done' },
|
||||
{ d: 'Apr 2026', t: 'RAG mit 375+ Dokumenten · 25k+ Controls', s: 'done' },
|
||||
{ d: '01 Mai 2026', t: 'EUIPO-Markenanmeldung (EU-weit)', s: 'next' },
|
||||
{ d: 'Aug 2026', t: 'GmbH-Gründung Breakpilot COMPLAI', s: 'planned' },
|
||||
{ d: 'Aug 2026', t: '2 zahlende Pilotkunden, erste Umsätze', s: 'planned' },
|
||||
{ d: 'Q3 2026', t: 'Public Beta-Launch', s: 'planned' },
|
||||
]
|
||||
const MS_EN = [
|
||||
{ d: 'Oct 2025', t: 'Founder grant & IHK Konstanz', s: 'done' },
|
||||
{ d: '11 Nov 2025', t: 'DPMA trademark filing BreakPilot', s: 'done' },
|
||||
{ d: '21 Nov 2025', t: 'Domain portfolio (.com, .de, .ai + typos)', s: 'done' },
|
||||
{ d: 'Jan 2026', t: 'Platform development started (500K+ LoC)', s: 'done' },
|
||||
{ d: '27 Mar 2026', t: 'DPMA trademark registration', s: 'done' },
|
||||
{ d: 'Apr 2026', t: 'RAG with 375+ documents · 25k+ controls', s: 'done' },
|
||||
{ d: '01 May 2026', t: 'EUIPO trademark filing (EU-wide)', s: 'next' },
|
||||
{ d: 'Aug 2026', t: 'GmbH incorporation Breakpilot COMPLAI', s: 'planned' },
|
||||
{ d: 'Aug 2026', t: '2 paying pilot customers, first revenue', s: 'planned' },
|
||||
{ d: 'Q3 2026', t: 'Public beta launch', s: 'planned' },
|
||||
]
|
||||
|
||||
export function PrintMilestonesPage({ milestones, lang, pageNum, totalPages, versionName }: SlideBase & { milestones: PitchMilestone[] }) {
|
||||
void milestones // we use the curated list above
|
||||
const de = lang === 'de'
|
||||
const items = de ? MS_DE : MS_EN
|
||||
const dotFor = (s: string) => s === 'done' ? COLORS.emerald600 : s === 'next' ? COLORS.amber600 : COLORS.slate400
|
||||
|
||||
return (
|
||||
<Page kicker="11" section={de ? 'TRACTION & MEILENSTEINE' : 'TRACTION & MILESTONES'} title={de ? 'Was wir bereits erreicht haben, und was als Nächstes kommt.' : 'What we have achieved, and what comes next.'} subtitle={de ? 'Wir gehen mit Substanz: bereits gebaut, nicht nur geplant. 500.000+ Zeilen Code, 25.000+ Controls, Markenschutz gesichert.' : 'We arrive with substance: built, not just planned. 500,000+ lines of code, 25,000+ controls, trademark secured.'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
|
||||
|
||||
{/* Top KPI strip */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: '4mm', padding: '4mm 0', borderTop: `1px solid ${COLORS.slate200}`, borderBottom: `1px solid ${COLORS.slate200}` }}>
|
||||
{[
|
||||
{ n: '500K+', l: de ? 'Lines of Code' : 'Lines of code' },
|
||||
{ n: '25.000+', l: de ? 'Atomare Controls' : 'Atomic controls' },
|
||||
{ n: '385', l: de ? 'Gesetze im RAG' : 'Laws in RAG' },
|
||||
{ n: '12', l: de ? 'Compliance-Module' : 'Compliance modules' },
|
||||
{ n: '2', l: de ? 'Pilotkunden (Aug 26)' : 'Pilot customers (Aug 26)' },
|
||||
].map((k, i) => (
|
||||
<div key={i}>
|
||||
<div style={{ fontSize: '20pt', fontWeight: 800, color: COLORS.indigo600, lineHeight: 1, fontVariantNumeric: 'tabular-nums' }}>{k.n}</div>
|
||||
<div style={{ fontSize: '7pt', color: COLORS.slate500, marginTop: '1.5mm', textTransform: 'uppercase', letterSpacing: '0.08em', fontWeight: 600 }}>{k.l}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '5mm', flex: 1, minHeight: 0, display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '6mm' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: '8pt', fontWeight: 700, color: COLORS.slate500, textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '2mm' }}>{de ? 'Erreicht (Okt 2025 – Apr 2026)' : 'Achieved (Oct 2025 – Apr 2026)'}</div>
|
||||
{items.filter(i => i.s === 'done').map((m, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'flex-start', gap: '4mm', padding: '2mm 0', borderBottom: `1px solid ${COLORS.slate100}` }}>
|
||||
<div style={{ flexShrink: 0, marginTop: '2pt', width: '6pt', height: '6pt', borderRadius: '50%', background: dotFor(m.s), WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: '7.5pt', color: COLORS.slate500, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.08em' }}>{m.d}</div>
|
||||
<div style={{ fontSize: '9pt', color: COLORS.slate900, fontWeight: 600, lineHeight: 1.4, marginTop: '0.5mm' }}>{m.t}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style={{ fontSize: '8pt', fontWeight: 700, color: COLORS.slate500, textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '2mm' }}>{de ? 'Nächste 4 Monate' : 'Next 4 months'}</div>
|
||||
{items.filter(i => i.s !== 'done').map((m, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'flex-start', gap: '4mm', padding: '2mm 0', borderBottom: `1px solid ${COLORS.slate100}` }}>
|
||||
<div style={{ flexShrink: 0, marginTop: '2pt', width: '6pt', height: '6pt', borderRadius: '50%', background: dotFor(m.s), border: m.s === 'next' ? 'none' : `1px solid ${COLORS.slate400}`, WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: '7.5pt', color: COLORS.slate500, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.08em' }}>{m.d} · {m.s === 'next' ? (de ? 'Nächster Schritt' : 'Next step') : (de ? 'Geplant' : 'Planned')}</div>
|
||||
<div style={{ fontSize: '9pt', color: COLORS.slate900, fontWeight: 600, lineHeight: 1.4, marginTop: '0.5mm' }}>{m.t}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
/* ===== TEAM ===== */
|
||||
|
||||
// Tuple shape: [LucideIcon, de_label, en_label]
|
||||
type TeamBullet = [typeof Briefcase, string, string]
|
||||
|
||||
const TEAM_INFO: Array<{ tagline: [string, string]; bullets: TeamBullet[] }> = [
|
||||
{
|
||||
tagline: [
|
||||
'Diplom-Ökonom mit 20+ Jahren Industrie- und Digitalisierungs-Erfahrung.',
|
||||
'Business economist with 20+ years in industry and digital transformation.',
|
||||
],
|
||||
bullets: [
|
||||
[Briefcase, '20+ Jahre Industrie, Strategie & Digitalisierung', '20+ yrs industry, strategy & digital transformation'],
|
||||
[RefreshCw, 'Aufbau IoT-, Blockchain- & KI-Plattformen', 'Built IoT, blockchain & AI platforms'],
|
||||
[Handshake, 'M&A: 4 Übernahmen & Beteiligungen geführt', 'M&A: led 4 acquisitions & investments'],
|
||||
[Scale, 'Regulatorik: DSGVO, MiCAR, CRA, Data Act', 'Regulatory: GDPR, MiCAR, CRA, Data Act'],
|
||||
[Lightbulb, '12 erteilte Patente (Erfinder/Miterfinder)', '12 granted patents (inventor / co-inventor)'],
|
||||
],
|
||||
},
|
||||
{
|
||||
tagline: [
|
||||
'Engineering Leader mit 15+ Jahren in Fintech, Web3 und Enterprise-KI.',
|
||||
'Engineering leader with 15+ years across fintech, Web3 and enterprise AI.',
|
||||
],
|
||||
bullets: [
|
||||
[Code, '15+ Jahre Engineering Leadership — Fintech, Web3, KI', '15+ yrs engineering leadership — fintech, Web3, AI'],
|
||||
[TrendingUp, 'Engineering-Org skaliert: 6 → 60 in 18 Monaten', 'Scaled engineering org: 6 → 60 in 18 months'],
|
||||
[CreditCard, 'ETOPay SaaS-Payment-Infrastruktur entwickelt', 'Built ETOPay SaaS payment infrastructure'],
|
||||
[ShieldCheck, 'MiCA-Compliance-Strategie ViviSwap', 'MiCA compliance strategy for ViviSwap'],
|
||||
[Cpu, 'Embedded Rust (Cortex-M) + Full-Stack TypeScript', 'Embedded Rust (Cortex-M) + full-stack TypeScript'],
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export function PrintTeamPage({ team, lang, pageNum, totalPages, versionName }: SlideBase & { team: PitchTeamMember[] }) {
|
||||
const de = lang === 'de'
|
||||
const members = team && team.length ? team : [
|
||||
{ id: 1, name: 'Benjamin Bönisch', role_de: 'CEO & Co-Founder', role_en: 'CEO & Co-Founder', bio_de: '', bio_en: '', equity_pct: 37.3, expertise: ['B2B Sales', 'Go-to-Market', 'Manufacturing', 'Operations'], linkedin_url: '', photo_url: '' },
|
||||
{ id: 2, name: 'Sharang Parnerkar', role_de: 'CTO & Co-Founder', role_en: 'CTO & Co-Founder', bio_de: '', bio_en: '', equity_pct: 37.3, expertise: ['AI Infrastructure', 'Distributed Systems', 'RAG', 'Go/Python/TypeScript'], linkedin_url: '', photo_url: '' },
|
||||
]
|
||||
|
||||
return (
|
||||
<Page kicker="13" section={de ? 'TEAM' : 'TEAM'} title={de ? 'Gründer mit Domain-Expertise.' : 'Founders with domain expertise.'} subtitle={de ? 'Komplementäres Gründerduo: Vertrieb + Engineering. Beide haben bereits skaliert. Beide kennen den Markt.' : 'Complementary founding duo: sales + engineering. Both have scaled. Both know the market.'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8mm', flex: 1, minHeight: 0 }}>
|
||||
{[0, 1].map(idx => {
|
||||
const m = members[idx]
|
||||
if (!m) return null
|
||||
const info = TEAM_INFO[idx]
|
||||
// Icon tile palette: violet for first founder, amber for second
|
||||
const tileBg = idx === 0 ? COLORS.violet50 : COLORS.amber50
|
||||
const tileColor = idx === 0 ? COLORS.violet700 : COLORS.amber700
|
||||
return (
|
||||
<div key={idx} style={{ border: `1px solid ${COLORS.slate200}`, borderTop: `3px solid ${COLORS.indigo600}`, padding: '5mm', display: 'flex', flexDirection: 'column', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
<div style={{ display: 'flex', gap: '5mm', alignItems: 'flex-start', marginBottom: '4mm' }}>
|
||||
{/* Photo */}
|
||||
<div style={{ width: '32mm', height: '32mm', flexShrink: 0, border: `1px solid ${COLORS.slate200}`, background: COLORS.slate100, overflow: 'hidden', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
{m.photo_url ? (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img src={m.photo_url} alt={m.name} style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
|
||||
) : (
|
||||
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '24pt', fontWeight: 800, color: COLORS.slate400, letterSpacing: '-0.02em' }}>
|
||||
{m.name?.split(' ').map(s => s[0]).slice(0, 2).join('') || '?'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Header text */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', marginBottom: '2mm' }}>
|
||||
<span style={{ fontSize: '7pt', fontWeight: 700, color: COLORS.indigo600, textTransform: 'uppercase', letterSpacing: '0.14em' }}>CO-FOUNDER · {String(idx + 1).padStart(2, '0')} / 02</span>
|
||||
<span style={{ fontSize: '7pt', color: COLORS.slate500, fontWeight: 700, fontVariantNumeric: 'tabular-nums' }}>{(m.equity_pct ?? 37.3).toLocaleString('de-DE')}% Equity</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '18pt', fontWeight: 800, color: COLORS.slate900, lineHeight: 1.05, marginBottom: '1mm', letterSpacing: '-0.015em' }}>{m.name}</div>
|
||||
<div style={{ fontSize: '10pt', fontWeight: 600, color: COLORS.indigo600 }}>{de ? m.role_de : m.role_en}</div>
|
||||
</div>
|
||||
</div>
|
||||
{info && (
|
||||
<div style={{ fontSize: '8.5pt', color: COLORS.slate700, fontWeight: 600, lineHeight: 1.4, marginBottom: '3mm' }}>{info.tagline[de ? 0 : 1]}</div>
|
||||
)}
|
||||
{/* Bulleted skill list with icons */}
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '1.5mm' }}>
|
||||
{info?.bullets.map(([IconComp, deLabel, enLabel], bi) => (
|
||||
<div key={bi} style={{ display: 'flex', alignItems: 'flex-start', gap: '3mm' }}>
|
||||
<div style={{ width: '7mm', height: '7mm', flexShrink: 0, background: tileBg, color: tileColor, display: 'flex', alignItems: 'center', justifyContent: 'center', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
<IconComp size={13} strokeWidth={2} />
|
||||
</div>
|
||||
<div style={{ flex: 1, fontSize: '9pt', color: COLORS.slate700, lineHeight: 1.4, paddingTop: '1mm' }}>{de ? deLabel : enLabel}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ marginTop: '4mm', paddingTop: '3mm', borderTop: `1px solid ${COLORS.slate200}` }}>
|
||||
<div style={{ fontSize: '7.5pt', fontWeight: 700, color: COLORS.slate500, textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '2mm' }}>{de ? 'Expertise' : 'Expertise'}</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '1.5mm' }}>
|
||||
{(m.expertise || []).map((e, i) => (
|
||||
<span key={i} style={{ fontSize: '8pt', padding: '0.5mm 2mm', background: COLORS.indigo50, color: COLORS.indigo700, fontWeight: 600, WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>{e}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '5mm', flexShrink: 0 }}>
|
||||
<Callout tone="accent" label={de ? 'Equity-Struktur' : 'Equity structure'}>
|
||||
{de
|
||||
? 'Gründer 74,6% · Pre-Seed-Investor 20% · ESOP-Pool 5,4%. Beide Gründer mit Vesting (4 Jahre, 1 Jahr Cliff). Keine Side-Projekte, keine externen Verpflichtungen.'
|
||||
: 'Founders 74.6% · Pre-Seed Investor 20% · ESOP pool 5.4%. Both founders with vesting (4 years, 1 year cliff). No side projects, no external commitments.'}
|
||||
</Callout>
|
||||
</div>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
/* ===== THE ASK ===== */
|
||||
|
||||
function formatTargetDate(raw: string | undefined, de: boolean): string {
|
||||
if (!raw) return de ? 'Q3 2026' : 'Q3 2026'
|
||||
// Accept ISO timestamps, ISO dates, or already-formatted strings.
|
||||
const d = new Date(raw)
|
||||
if (isNaN(d.getTime())) return raw
|
||||
const months = de
|
||||
? ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez']
|
||||
: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
|
||||
return `${months[d.getUTCMonth()]} ${d.getUTCFullYear()}`
|
||||
}
|
||||
|
||||
function formatFunding(amount: number): string {
|
||||
if (amount >= 1_000_000) {
|
||||
const m = amount / 1_000_000
|
||||
return '€' + (m % 1 === 0 ? m.toFixed(0) : m.toFixed(1)) + 'M'
|
||||
}
|
||||
return '€' + Math.round(amount / 1_000) + 'k'
|
||||
}
|
||||
|
||||
export function PrintTheAskPage({ funding, lang, pageNum, totalPages, versionName }: SlideBase & { funding: PitchFunding }) {
|
||||
const de = lang === 'de'
|
||||
const amount = funding?.amount_eur || 400_000
|
||||
const instrument = funding?.instrument || (de ? 'Wandeldarlehen' : 'Convertible Loan')
|
||||
const isConvertible = (instrument || '').toLowerCase().includes('wandeldarlehen') ||
|
||||
(instrument || '').toLowerCase().includes('convertible') ||
|
||||
(instrument || '').toLowerCase().includes('safe')
|
||||
// For equity rounds we display Pre/Post/Investor-Share computed from a 20%
|
||||
// assumed investor share. For convertibles those fields don't apply — show
|
||||
// typical convertible terms instead (Discount, Maturity, Interest).
|
||||
const equityShare = 0.20
|
||||
const postMoney = amount / equityShare
|
||||
const preMoney = postMoney - amount
|
||||
const fmtBig = (n: number) => n >= 1_000_000
|
||||
? '€' + (n / 1_000_000).toFixed(n % 1_000_000 === 0 ? 0 : 1) + 'M'
|
||||
: '€' + Math.round(n / 1_000) + 'k'
|
||||
const termTiles: [string, string][] = isConvertible
|
||||
? [
|
||||
[de ? 'Funding' : 'Funding', formatFunding(amount)],
|
||||
[de ? 'Discount' : 'Discount', '20%'],
|
||||
[de ? 'Laufzeit' : 'Maturity', de ? '24 Monate' : '24 months'],
|
||||
[de ? 'INVEST-Zuschuss' : 'INVEST grant', '20%'],
|
||||
]
|
||||
: [
|
||||
[de ? 'Funding' : 'Funding', formatFunding(amount)],
|
||||
[de ? 'Pre-Money' : 'Pre-money', fmtBig(preMoney)],
|
||||
[de ? 'Post-Money' : 'Post-money', fmtBig(postMoney)],
|
||||
[de ? 'Investor-Anteil' : 'Investor share', Math.round(equityShare * 100) + '%'],
|
||||
]
|
||||
const useOfFunds = funding?.use_of_funds || [
|
||||
{ category: 'engineering', percentage: 45, label_de: 'Engineering & Produkt', label_en: 'Engineering & Product' },
|
||||
{ category: 'sales', percentage: 30, label_de: 'Vertrieb & Marketing', label_en: 'Sales & Marketing' },
|
||||
{ category: 'hardware', percentage: 10, label_de: 'Infrastruktur & Hardware', label_en: 'Infrastructure & Hardware' },
|
||||
{ category: 'legal', percentage: 10, label_de: 'Legal & Compliance', label_en: 'Legal & Compliance' },
|
||||
{ category: 'reserve', percentage: 5, label_de: 'Reserve', label_en: 'Reserve' },
|
||||
]
|
||||
|
||||
return (
|
||||
<Page kicker="14" section={de ? 'THE ASK' : 'THE ASK'} title={de ? `${funding?.round_name || 'Pre-Seed'}: ${formatFunding(amount)} via ${instrument}.` : `${funding?.round_name || 'Pre-Seed'}: ${formatFunding(amount)} via ${instrument.toLowerCase()}.`} subtitle={de ? '18 Monate Runway zur Profitabilität. Use of Funds in 5 Buckets. INVEST-Zuschuss 20% rückzahlbar.' : '18-month runway to profitability. Use of funds across 5 buckets. 20% INVEST grant eligible.'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1.2fr 1fr', gap: '8mm', flex: 1, minHeight: 0 }}>
|
||||
{/* Hero amount */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ borderLeft: `3px solid ${COLORS.indigo600}`, paddingLeft: '5mm', marginBottom: '6mm' }}>
|
||||
<div style={{ fontSize: '8pt', fontWeight: 700, color: COLORS.indigo600, textTransform: 'uppercase', letterSpacing: '0.12em' }}>{de ? 'Funding' : 'Funding'}</div>
|
||||
<div style={{ fontSize: '54pt', fontWeight: 800, color: COLORS.slate900, lineHeight: 1, letterSpacing: '-0.03em', fontVariantNumeric: 'tabular-nums', marginTop: '2mm' }}>
|
||||
{formatFunding(amount)}
|
||||
</div>
|
||||
<div style={{ fontSize: '10pt', color: COLORS.slate600, marginTop: '3mm' }}>{instrument} · {funding?.round_name || 'Pre-Seed'} · {de ? 'Zielabschluss' : 'Target close'}: {formatTargetDate(funding?.target_date, de)}</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(' + termTiles.length + ', 1fr)', gap: '4mm' }}>
|
||||
{termTiles.map(([label, val], i) => (
|
||||
<div key={i} style={{ border: `1px solid ${COLORS.slate200}`, padding: '3mm' }}>
|
||||
<div style={{ fontSize: '7.5pt', color: COLORS.slate500, textTransform: 'uppercase', letterSpacing: '0.08em', fontWeight: 600 }}>{label}</div>
|
||||
<div style={{ fontSize: '16pt', fontWeight: 800, color: COLORS.slate900, marginTop: '1mm', fontVariantNumeric: 'tabular-nums' }}>{val}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 'auto' }}>
|
||||
<Callout tone="accent" label={de ? 'Was wir damit erreichen' : 'What this gets us'}>
|
||||
{de
|
||||
? '18 Monate Runway · GmbH-Gründung · Engineering-Team auf 5 erweitert · 50–100 Kunden onboarded · ARR €1,5–2,5M in 18 Monaten · Vorbereitung Series A.'
|
||||
: '18-month runway · GmbH incorporated · engineering team scaled to 5 · 50–100 customers onboarded · ARR €1.5–2.5M in 18 months · Series A readiness.'}
|
||||
</Callout>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Use of funds */}
|
||||
<div>
|
||||
<div style={{ fontSize: '8pt', fontWeight: 700, color: COLORS.slate500, textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '3mm' }}>{de ? 'Use of Funds' : 'Use of funds'}</div>
|
||||
{(() => {
|
||||
const palette = [COLORS.indigo600, COLORS.indigo500, COLORS.amber600, COLORS.slate600, COLORS.slate400]
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '6mm' }}>
|
||||
<div style={{ flexShrink: 0 }}>
|
||||
<DonutChart
|
||||
size={48}
|
||||
thickness={9}
|
||||
segments={useOfFunds.map((u, i) => ({ label: de ? u.label_de : u.label_en, pct: u.percentage, color: palette[i % palette.length] }))}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{useOfFunds.map((u, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: '3mm', padding: '2mm 0', borderBottom: `1px solid ${COLORS.slate100}` }}>
|
||||
<div style={{ width: '3mm', height: '3mm', background: palette[i % palette.length], flexShrink: 0, WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }} />
|
||||
<div style={{ flex: 1, fontSize: '8.5pt', color: COLORS.slate800, fontWeight: 500 }}>{de ? u.label_de : u.label_en}</div>
|
||||
<div style={{ fontSize: '11pt', fontWeight: 800, color: COLORS.slate900, fontVariantNumeric: 'tabular-nums', minWidth: '12mm', textAlign: 'right' }}>{u.percentage}%</div>
|
||||
<div style={{ fontSize: '7.5pt', color: COLORS.slate500, fontVariantNumeric: 'tabular-nums', minWidth: '14mm', textAlign: 'right' }}>€{(amount * u.percentage / 100 / 1000).toFixed(0)}k</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
/* ===== CUSTOMER SAVINGS ===== */
|
||||
|
||||
export function PrintCustomerSavingsPage({ lang, pageNum, totalPages, versionName }: SlideBase) {
|
||||
const de = lang === 'de'
|
||||
const items = [
|
||||
{ l: de ? 'Pentests' : 'Pentests', today: 15000, bp: 13000 },
|
||||
{ l: de ? 'CE-SW-Risiko' : 'CE software risk', today: 12000, bp: 9000 },
|
||||
{ l: de ? 'Compliance-Zeit' : 'Compliance time', today: 18000, bp: 15000 },
|
||||
{ l: de ? 'Audit-Vorber.' : 'Audit prep', today: 9000, bp: 9000 },
|
||||
{ l: de ? 'Legal (DSGVO/AI Act)' : 'Legal (GDPR/AI Act)', today: 8000, bp: 5000 },
|
||||
{ l: de ? 'Auditmanager-Software' : 'Audit manager SW', today: 5000, bp: 0 },
|
||||
{ l: de ? 'Schulungen extern' : 'External training', today: 4000, bp: 4000 },
|
||||
]
|
||||
const totalToday = items.reduce((s, i) => s + i.today, 0)
|
||||
const totalSaved = items.reduce((s, i) => s + i.bp, 0)
|
||||
const totalBpCost = 25000
|
||||
|
||||
return (
|
||||
<Page kicker="15" section={de ? 'KUNDENERSPARNIS' : 'CUSTOMER SAVINGS'} title={de ? 'Kunden sparen mehr als sie zahlen, vom ersten Tag an.' : 'Customers save more than they pay, from day one.'} subtitle={de ? 'Detaillierte Aufschlüsselung für ein typisches KMU (50 MA) im ersten Jahr. €25k Kosten, €55k Ersparnis.' : 'Detailed breakdown for a typical SME (50 emp.) in year one. €25k cost, €55k savings.'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
|
||||
|
||||
{/* Big stat header */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '4mm', padding: '4mm 0', borderTop: `1px solid ${COLORS.slate200}`, borderBottom: `1px solid ${COLORS.slate200}`, marginBottom: '5mm' }}>
|
||||
{[
|
||||
{ l: de ? 'Heute (ohne BP)' : 'Today (without BP)', v: '€' + (totalToday / 1000).toFixed(0) + 'k', tone: COLORS.red700 },
|
||||
{ l: de ? 'BreakPilot Pro / Jahr' : 'BreakPilot Pro / year', v: '€' + (totalBpCost / 1000).toFixed(0) + 'k', tone: COLORS.indigo600 },
|
||||
{ l: de ? 'Ersparnis / KMU' : 'Savings / SME', v: '€' + (totalSaved / 1000).toFixed(0) + 'k', tone: COLORS.emerald700 },
|
||||
{ l: de ? 'Netto-Effekt' : 'Net effect', v: '+€' + ((totalSaved - totalBpCost) / 1000).toFixed(0) + 'k', tone: COLORS.emerald700 },
|
||||
].map((k, i) => (
|
||||
<div key={i}>
|
||||
<div style={{ fontSize: '26pt', fontWeight: 800, color: k.tone, lineHeight: 1, fontVariantNumeric: 'tabular-nums', letterSpacing: '-0.02em' }}>{k.v}</div>
|
||||
<div style={{ fontSize: '7.5pt', color: COLORS.slate500, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.08em', marginTop: '1.5mm' }}>{k.l}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Bar comparison */}
|
||||
<div style={{ flex: 1, minHeight: 0, display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8mm' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: '8pt', fontWeight: 700, color: COLORS.slate500, textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '3mm' }}>{de ? 'Heute vs. mit BreakPilot (€/Jahr/KMU)' : 'Today vs. with BreakPilot (€/yr/SME)'}</div>
|
||||
<ComparisonBars
|
||||
rows={items.map(it => ({
|
||||
label: it.l,
|
||||
bars: [
|
||||
{ tone: 'negative', value: it.today, cap: de ? 'Heute' : 'Today' },
|
||||
{ tone: 'positive', value: it.bp, cap: de ? 'gespart mit BP' : 'saved with BP' },
|
||||
],
|
||||
}))}
|
||||
formatValue={(n) => '€' + (n / 1000).toFixed(0) + 'k'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style={{ fontSize: '8pt', fontWeight: 700, color: COLORS.slate500, textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '3mm' }}>{de ? 'Ersparnis-Aufschlüsselung' : 'Savings breakdown'}</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0' }}>
|
||||
<StatLine label={de ? 'Pentests (kontinuierlich, inklusive)' : 'Pentests (continuous, included)'} value="€13.000" tone="positive" />
|
||||
<StatLine label={de ? 'CE-Risiko (Code-basiert, inkl.)' : 'CE risk (code-based, incl.)'} value="€9.000" tone="positive" />
|
||||
<StatLine label={de ? 'Compliance-Zeit (−80%)' : 'Compliance time (−80%)'} value="€15.000" tone="positive" />
|
||||
<StatLine label={de ? 'Audit-Vorber. (auf Knopfdruck)' : 'Audit prep (one-click)'} value="€9.000" tone="positive" />
|
||||
<StatLine label={de ? 'Legal-Stunden (−60%)' : 'Legal hours (−60%)'} value="€5.000" tone="positive" />
|
||||
<StatLine label={de ? 'Schulungen (Academy inkl.)' : 'Training (Academy incl.)'} value="€4.000" tone="positive" />
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '5mm' }}>
|
||||
<Callout tone="negative" label={de ? 'Versteckte Kosten' : 'Hidden costs'}>
|
||||
{de
|
||||
? 'Zeit der GF + Compliance-Beauftragten (~30 Tage/Jahr), DSGVO-Bußgelder (bis 4% Jahresumsatz), verlorene RFQs durch fehlende Compliance-Nachweise. Nicht in obigen Zahlen enthalten.'
|
||||
: 'Time of management + compliance officer (~30 days/year), GDPR fines (up to 4% annual revenue), lost RFQs from missing compliance evidence. Not included in numbers above.'}
|
||||
</Callout>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
import { Language, FMResult } from '@/lib/types'
|
||||
import { Page, COLORS, Bullets } from './PrintLayout'
|
||||
import {
|
||||
ScanLine, Shield, Database, Brain, ShieldCheck, Lock, MessageSquare, Wrench,
|
||||
Layers, Sparkles, TrendingUp, Globe,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
import { getDetails } from '@/components/slides/USPSlide.data'
|
||||
import { computeAnnualKPIs } from '@/lib/finanzplan/annual-kpis'
|
||||
|
||||
interface SlideBase { lang: Language; pageNum: number; totalPages: number; versionName: string }
|
||||
|
||||
const MONO = "'JetBrains Mono', ui-monospace, monospace"
|
||||
|
||||
/* ====================================================================== */
|
||||
/* TL;DR — 02 · 30 Sekunden (4 quad cards) */
|
||||
/* ====================================================================== */
|
||||
|
||||
export function PrintTLDRPage({ lang, pageNum, totalPages, versionName }: SlideBase) {
|
||||
const de = lang === 'de'
|
||||
|
||||
const cards = de ? [
|
||||
{ kicker: '01 · Scale', title: '25.000+ Controls', body: 'Atomare Prüfaspekte über DSGVO, AI Act, NIS-2, CRA, MaschVO und 380+ weitere Quellen.', ticker: 'idx 26.123 atomic checks', tint: COLORS.violet600 },
|
||||
{ kicker: '02 · Sovereignty', title: '100 % EU-souverän', body: 'BSI-C5-zertifizierte Cloud in Deutschland und Frankreich. Keine US-Anbieter. Air-gap-fähig.', ticker: 'region BSI C5 · DE/FR', tint: COLORS.violet500 },
|
||||
{ kicker: '03 · Bidirectional', title: 'Compliance ↔ Code', body: 'Policy-Änderungen fliessen in den Code; Code-Änderungen aktualisieren Policies. Zero Drift.', ticker: 'sync policy.md → controller.ts', tint: COLORS.amber600 },
|
||||
{ kicker: '04 · Speed', title: '<20 Tage audit-ready', body: 'Vom Vertrag zum auditfähigen Status: typischerweise 14–20 Tage. White-Glove-Onboarding.', ticker: 'tag 17 bis audit-ready', tint: COLORS.amber700 },
|
||||
] : [
|
||||
{ kicker: '01 · Scale', title: '25,000+ Controls', body: 'Atomic audit aspects across GDPR, AI Act, NIS-2, CRA, Machinery Reg. and 380+ other sources.', ticker: 'idx 26,123 atomic checks', tint: COLORS.violet600 },
|
||||
{ kicker: '02 · Sovereignty', title: '100 % EU-sovereign', body: 'BSI-C5 certified cloud in Germany and France. No US vendors. Air-gap capable.', ticker: 'region BSI C5 · DE/FR', tint: COLORS.violet500 },
|
||||
{ kicker: '03 · Bidirectional', title: 'Compliance ↔ Code', body: 'Policy edits flow into code; code changes update policies. Zero drift.', ticker: 'sync policy.md → controller.ts', tint: COLORS.amber600 },
|
||||
{ kicker: '04 · Speed', title: '<20 days audit-ready', body: 'From contract to audit-ready: typically 14–20 days. White-glove onboarding.', ticker: 'day 17 to audit-ready', tint: COLORS.amber700 },
|
||||
]
|
||||
|
||||
return (
|
||||
<Page kicker="02" section={de ? '30 SEKUNDEN' : '30 SECONDS'} title={de ? 'Was BreakPilot in einem Satz.' : 'BreakPilot in one sentence.'} subtitle={de ? 'Kontinuierliche Compliance & Security für den industriellen Mittelstand — EU-souverän, Bidirektional, in unter 20 Tagen produktiv.' : 'Continuous compliance & security for the industrial mid-market — EU-sovereign, bidirectional, productive in under 20 days.'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gridTemplateRows: '1fr 1fr', gap: '5mm', flex: 1, minHeight: 0 }}>
|
||||
{cards.map((c, i) => (
|
||||
<div key={i} style={{ border: `1px solid ${c.tint}55`, borderTop: `3px solid ${c.tint}`, borderRadius: '4pt', background: `linear-gradient(135deg, ${c.tint}10 0%, #ffffff 100%)`, padding: '5mm 6mm', display: 'flex', flexDirection: 'column', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
<div style={{ fontFamily: MONO, fontSize: '8pt', fontWeight: 700, color: c.tint, textTransform: 'uppercase', letterSpacing: '0.22em', marginBottom: '3mm' }}>{c.kicker}</div>
|
||||
<div style={{ fontSize: '22pt', fontWeight: 800, color: COLORS.slate900, lineHeight: 1.05, letterSpacing: '-0.025em', marginBottom: '3mm' }}>{c.title}</div>
|
||||
<div style={{ fontSize: '9pt', color: COLORS.slate700, lineHeight: 1.5, flex: 1 }}>{c.body}</div>
|
||||
<div style={{ marginTop: '3mm', paddingTop: '2mm', borderTop: `1px solid ${COLORS.slate200}`, display: 'flex', alignItems: 'center', gap: '2mm' }}>
|
||||
<span style={{ width: '2mm', height: '2mm', borderRadius: '50%', background: COLORS.emerald600, WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }} />
|
||||
<span style={{ fontFamily: MONO, fontSize: '7pt', color: c.tint, fontWeight: 700 }}>{c.ticker}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
/* ====================================================================== */
|
||||
/* DIFFERENTIATORS — 4 under-the-hood cards (moved out of USP p2) */
|
||||
/* ====================================================================== */
|
||||
|
||||
const DIFF_ICON: Record<string, LucideIcon> = {
|
||||
trace: Layers,
|
||||
engine: Sparkles,
|
||||
opt: TrendingUp,
|
||||
stack: Globe,
|
||||
}
|
||||
|
||||
export function PrintDifferentiatorsPage({ lang, pageNum, totalPages, versionName }: SlideBase) {
|
||||
const de = lang === 'de'
|
||||
const d = getDetails(de)
|
||||
const cards = ['trace', 'engine', 'opt', 'stack'] as const
|
||||
const tickers = de
|
||||
? ['trace 13.605 evidence-chain', 'validate 1.450 · 98,9 %', 'optimize gap → policy §4.2', 'check BSI C5 · 1.382']
|
||||
: ['trace 13,605 evidence-chain', 'validate 1,450 · 98.9 %', 'optimize gap → policy §4.2', 'check BSI C5 · 1,382']
|
||||
|
||||
return (
|
||||
<Page kicker="06" section={de ? 'DIFFERENTIATORS' : 'DIFFERENTIATORS'} title={de ? 'Vier technische Differentiator.' : 'Four technical differentiators.'} subtitle={de ? 'Was kein anderer Anbieter geschlossen liefert: Traceability, kontinuierliche Engine, Compliance-Optimizer, EU-Trust-Stack.' : 'What no other vendor delivers end-to-end: traceability, continuous engine, compliance optimizer, EU-trust stack.'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '4mm', flex: 1, minHeight: 0 }}>
|
||||
{cards.map((k, i) => {
|
||||
const p = d[k]
|
||||
const Icon = DIFF_ICON[k]
|
||||
const tint = i < 2 ? COLORS.violet600 : COLORS.amber600
|
||||
const tintDark = i < 2 ? COLORS.violet700 : COLORS.amber700
|
||||
const tintLight = i < 2 ? COLORS.violet50 : COLORS.amber50
|
||||
return (
|
||||
<div key={k} style={{ border: `1px solid ${tint}40`, background: `linear-gradient(135deg, ${tintLight} 0%, #ffffff 100%)`, borderRadius: '4pt', padding: '4mm 5mm', display: 'flex', flexDirection: 'column', overflow: 'hidden', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
<div style={{ width: '9mm', height: '9mm', background: '#ffffff', borderRadius: '2pt', border: `1px solid ${tint}`, display: 'flex', alignItems: 'center', justifyContent: 'center', color: tint, marginBottom: '3mm', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
{Icon && <Icon size={16} strokeWidth={1.5} />}
|
||||
</div>
|
||||
<div style={{ fontFamily: MONO, fontSize: '7pt', fontWeight: 700, color: tintDark, textTransform: 'uppercase', letterSpacing: '0.16em', marginBottom: '1mm' }}>{String(i + 1).padStart(2, '0')} · {p.kicker.replace(/^Säule[\s·]+|^Under the Hood|^Pillar[\s·]+/i, '').trim() || p.kicker}</div>
|
||||
<div style={{ fontSize: '12pt', fontWeight: 700, color: COLORS.slate900, lineHeight: 1.15, marginBottom: '2.5mm', letterSpacing: '-0.005em' }}>{p.title}</div>
|
||||
<div style={{ fontSize: '8pt', color: COLORS.slate700, lineHeight: 1.45, marginBottom: '2.5mm' }}>{p.body}</div>
|
||||
{p.bullets && <Bullets dense items={p.bullets} />}
|
||||
<div style={{ marginTop: 'auto', paddingTop: '2mm', borderTop: `1px solid ${COLORS.slate200}`, display: 'flex', alignItems: 'center', gap: '2mm' }}>
|
||||
<span style={{ width: '2mm', height: '2mm', borderRadius: '50%', background: COLORS.emerald600, WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }} />
|
||||
<span style={{ fontFamily: MONO, fontSize: '6.5pt', color: tintDark, fontWeight: 700 }}>{tickers[i]}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
/* ====================================================================== */
|
||||
/* KPI HERO — 8 trajectory tiles 2026 → 2030 */
|
||||
/* ====================================================================== */
|
||||
|
||||
function fmtEur(n: number): string {
|
||||
const abs = Math.abs(n)
|
||||
const sign = n < 0 ? '−' : ''
|
||||
if (abs >= 1e6) return `${sign}€${(abs / 1e6).toLocaleString('de-DE', { maximumFractionDigits: 1 })}M`
|
||||
if (abs >= 1e3) return `${sign}€${Math.round(abs / 1e3)}k`
|
||||
return `${sign}€${Math.round(abs)}`
|
||||
}
|
||||
|
||||
/* Small inline SVG sparkline. Renders an empty-state em-dash if all values are zero. */
|
||||
function Sparkline({ values, width = 56, height = 22, stroke = COLORS.violet600 }: { values: number[]; width?: number; height?: number; stroke?: string }) {
|
||||
const allZero = values.every(v => v === 0)
|
||||
if (allZero || values.length < 2) {
|
||||
return <span style={{ fontFamily: MONO, fontSize: '11pt', color: COLORS.slate300, fontWeight: 700, lineHeight: 1 }}>—</span>
|
||||
}
|
||||
const min = Math.min(...values)
|
||||
const max = Math.max(...values)
|
||||
const range = max - min || 1
|
||||
const stepX = width / (values.length - 1)
|
||||
const padY = 2
|
||||
const innerH = height - padY * 2
|
||||
const points = values.map((v, i) => {
|
||||
const x = i * stepX
|
||||
const y = padY + innerH - ((v - min) / range) * innerH
|
||||
return { x, y }
|
||||
})
|
||||
const path = points.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x.toFixed(2)},${p.y.toFixed(2)}`).join(' ')
|
||||
const last = points[points.length - 1]
|
||||
// Area fill polygon for soft tone under the line
|
||||
const area = `${path} L${last.x.toFixed(2)},${height} L0,${height} Z`
|
||||
return (
|
||||
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} style={{ display: 'block', overflow: 'visible' }}>
|
||||
<path d={area} fill={stroke} fillOpacity={0.08} />
|
||||
<path d={path} fill="none" stroke={stroke} strokeWidth={1.2} strokeLinecap="round" strokeLinejoin="round" />
|
||||
<circle cx={last.x} cy={last.y} r={1.6} fill={stroke} />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function PrintKPIHeroPage({ fmResults, lang, pageNum, totalPages, versionName }: SlideBase & { fmResults: FMResult[] }) {
|
||||
const de = lang === 'de'
|
||||
const kpis = computeAnnualKPIs(fmResults)
|
||||
const k26 = kpis.find(k => k.year === 2026)
|
||||
const k30 = kpis.find(k => k.year === 2030)
|
||||
const breakEvenYear = kpis.find(k => k.ebit > 0)?.year
|
||||
const series = (pick: (k: typeof kpis[number]) => number): number[] =>
|
||||
[2026, 2027, 2028, 2029, 2030].map(y => {
|
||||
const row = kpis.find(k => k.year === y)
|
||||
return row ? pick(row) : 0
|
||||
})
|
||||
|
||||
type Tile = { label: string; start: string; end: string; endColor: string; series: number[]; hideStart?: boolean; stroke?: string }
|
||||
const tiles: Tile[] = k26 && k30 ? [
|
||||
{ label: 'ARR', start: fmtEur(k26.arr), end: fmtEur(k30.arr), endColor: COLORS.violet600, series: series(k => k.arr), hideStart: k26.arr === 0 },
|
||||
{ label: de ? 'Kunden' : 'Customers', start: k26.customers.toLocaleString('de-DE'), end: k30.customers.toLocaleString('de-DE'), endColor: COLORS.violet600, series: series(k => k.customers), hideStart: k26.customers === 0 },
|
||||
{ label: de ? 'ARPU / Mo' : 'ARPU / mo', start: fmtEur(k26.arpu), end: fmtEur(k30.arpu), endColor: COLORS.slate900, series: series(k => k.arpu), hideStart: k26.arpu === 0 },
|
||||
{ label: de ? 'Mitarbeiter' : 'Employees', start: String(k26.employees), end: String(k30.employees), endColor: COLORS.slate900, series: series(k => k.employees), hideStart: k26.employees === 0 },
|
||||
{ label: de ? 'Bruttomarge' : 'Gross margin', start: `${k26.grossMargin}%`, end: `${k30.grossMargin}%`, endColor: COLORS.emerald700, series: series(k => k.grossMargin), hideStart: k26.grossMargin === 0, stroke: COLORS.emerald600 },
|
||||
{ label: 'EBIT', start: fmtEur(k26.ebit), end: fmtEur(k30.ebit), endColor: k30.ebit >= 0 ? COLORS.emerald700 : COLORS.red700, series: series(k => k.ebit), hideStart: k26.ebit === 0, stroke: k30.ebit >= 0 ? COLORS.emerald600 : COLORS.red600 },
|
||||
{ label: de ? 'Netto-Ergebnis' : 'Net income', start: fmtEur(k26.netIncome), end: fmtEur(k30.netIncome), endColor: k30.netIncome >= 0 ? COLORS.emerald700 : COLORS.red700, series: series(k => k.netIncome), hideStart: k26.netIncome === 0, stroke: k30.netIncome >= 0 ? COLORS.emerald600 : COLORS.red600 },
|
||||
{ label: de ? 'Cash (Dez)' : 'Cash (Dec)', start: fmtEur(k26.cashBalance), end: fmtEur(k30.cashBalance), endColor: COLORS.emerald700, series: series(k => k.cashBalance), hideStart: k26.cashBalance === 0, stroke: COLORS.emerald600 },
|
||||
] : []
|
||||
|
||||
return (
|
||||
<Page kicker="18" section={de ? 'ANHANG · KENNZAHLEN' : 'APPENDIX · KEY METRICS'} title={de ? 'Trajektorie 2026 → 2030.' : 'Trajectory 2026 → 2030.'} subtitle={de ? `Acht investorrelevante KPIs auf einen Blick. Base-Case-Szenario, abgeleitet aus dem Finanzplan. Break-Even: ${breakEvenYear ?? 'Q3 2029'}.` : `Eight investor-relevant KPIs at a glance. Base-case scenario, derived from the financial plan. Break-even: ${breakEvenYear ?? 'Q3 2029'}.`} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
|
||||
|
||||
{tiles.length === 0 ? (
|
||||
<p style={{ fontSize: '10pt', color: COLORS.slate600 }}>{de ? 'Keine Finanzdaten verfügbar.' : 'No financial data available.'}</p>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gridTemplateRows: '1fr 1fr', gap: '5mm', flex: 1, minHeight: 0 }}>
|
||||
{tiles.map((t, i) => (
|
||||
<div key={i} style={{ border: `1px solid ${COLORS.slate200}`, borderTop: `3px solid ${COLORS.violet600}`, background: '#ffffff', padding: '5mm', display: 'flex', flexDirection: 'column', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
<div style={{ fontFamily: MONO, fontSize: '7.5pt', fontWeight: 700, color: COLORS.slate500, textTransform: 'uppercase', letterSpacing: '0.18em', marginBottom: '3.5mm' }}>{t.label}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: '3mm', marginBottom: '3.5mm' }}>
|
||||
{!t.hideStart && (
|
||||
<>
|
||||
<span style={{ fontSize: '11pt', fontWeight: 600, color: COLORS.slate400, fontVariantNumeric: 'tabular-nums' }}>{t.start}</span>
|
||||
<span style={{ fontFamily: MONO, fontSize: '11pt', color: COLORS.slate400, fontWeight: 700 }}>→</span>
|
||||
</>
|
||||
)}
|
||||
<span style={{ fontSize: '24pt', fontWeight: 800, color: t.endColor, lineHeight: 1, fontVariantNumeric: 'tabular-nums', letterSpacing: '-0.025em' }}>{t.end}</span>
|
||||
</div>
|
||||
<div style={{ marginTop: 'auto', display: 'flex', flexDirection: 'column', gap: '1.5mm' }}>
|
||||
<Sparkline values={t.series} stroke={t.stroke ?? COLORS.violet600} />
|
||||
<div style={{ paddingTop: '1.5mm', borderTop: `1px solid ${COLORS.slate100}`, fontFamily: MONO, fontSize: '6.5pt', color: COLORS.slate500, textTransform: 'uppercase', letterSpacing: '0.12em', fontWeight: 700, display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span>2026</span><span>2030</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
/* ====================================================================== */
|
||||
/* TECH STACK — 8-category grid */
|
||||
/* ====================================================================== */
|
||||
|
||||
export function PrintTechStackPage({ lang, pageNum, totalPages, versionName }: SlideBase) {
|
||||
const de = lang === 'de'
|
||||
|
||||
const cats = de ? [
|
||||
{ name: 'Frontend', icon: ScanLine, blurb: 'User-facing Oberflächen', items: ['Next.js 15', 'React 19', 'Tailwind CSS', 'Framer Motion', 'Dioxus (Rust)'] },
|
||||
{ name: 'Backend', icon: Wrench, blurb: 'API & Business-Logik', items: ['Go/Gin', 'Python/FastAPI', 'Rust/Axum', 'OpenAPI'] },
|
||||
{ name: 'Storage', icon: Database, blurb: 'Persistenter Zustand', items: ['PostgreSQL 16', 'MongoDB', 'Qdrant Vector DB', 'Valkey (cache)'] },
|
||||
{ name: 'KI / RAG', icon: Brain, blurb: 'Inferenz & Retrieval', items: ['LiteLLM', 'Qwen3-32B', 'DeepSeek-R1', 'Sentence-Transformers', 'LangGraph'] },
|
||||
{ name: 'Code-Scanning', icon: Shield, blurb: 'Schwachstellen-Erkennung', items: ['Semgrep', 'Gitleaks', 'Syft', 'Trivy', 'CycloneDX'] },
|
||||
{ name: 'Auth & SSO', icon: Lock, blurb: 'Identität & Rechte', items: ['Keycloak', 'OIDC', 'OPA (policies)'] },
|
||||
{ name: 'Kommunikation', icon: MessageSquare, blurb: 'Echtzeit-Kanäle', items: ['Matrix (chat)', 'Jitsi (video)', 'Mailpit'] },
|
||||
{ name: 'DevOps', icon: ShieldCheck, blurb: 'Build & Ship', items: ['Gitea', 'Woodpecker CI', 'HashiCorp Vault', 'Orca', 'Docker Compose'] },
|
||||
] : [
|
||||
{ name: 'Frontend', icon: ScanLine, blurb: 'User-facing surfaces', items: ['Next.js 15', 'React 19', 'Tailwind CSS', 'Framer Motion', 'Dioxus (Rust)'] },
|
||||
{ name: 'Backend', icon: Wrench, blurb: 'API & business logic', items: ['Go/Gin', 'Python/FastAPI', 'Rust/Axum', 'OpenAPI'] },
|
||||
{ name: 'Storage', icon: Database, blurb: 'Persistent state', items: ['PostgreSQL 16', 'MongoDB', 'Qdrant vector DB', 'Valkey (cache)'] },
|
||||
{ name: 'AI / RAG', icon: Brain, blurb: 'Inference & retrieval', items: ['LiteLLM', 'Qwen3-32B', 'DeepSeek-R1', 'Sentence-Transformers', 'LangGraph'] },
|
||||
{ name: 'Code scanning', icon: Shield, blurb: 'Vulnerability detection', items: ['Semgrep', 'Gitleaks', 'Syft', 'Trivy', 'CycloneDX'] },
|
||||
{ name: 'Auth & SSO', icon: Lock, blurb: 'Identity & permissions', items: ['Keycloak', 'OIDC', 'OPA (policies)'] },
|
||||
{ name: 'Communication', icon: MessageSquare, blurb: 'Real-time channels', items: ['Matrix (chat)', 'Jitsi (video)', 'Mailpit'] },
|
||||
{ name: 'DevOps', icon: ShieldCheck, blurb: 'Build & ship', items: ['Gitea', 'Woodpecker CI', 'HashiCorp Vault', 'Orca', 'Docker Compose'] },
|
||||
]
|
||||
|
||||
return (
|
||||
<Page kicker="26" section={de ? 'ANHANG · TECH-STACK' : 'APPENDIX · TECH STACK'} title={de ? '8 Kategorien. Polyglott. 100% Open Source.' : '8 categories. Polyglot. 100% open source.'} subtitle={de ? 'Alle Komponenten mit kommerziell nutzbarer Lizenz (MIT, Apache-2.0, BSD, ISC, MPL-2.0, LGPL). Keine GPL/AGPL. Keine US-SaaS-Abhängigkeit.' : 'All components carry a commercially usable license (MIT, Apache-2.0, BSD, ISC, MPL-2.0, LGPL). No GPL/AGPL. No US SaaS dependency.'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gridTemplateRows: '1fr 1fr', gap: '4mm', flex: 1, minHeight: 0 }}>
|
||||
{cats.map((c, i) => {
|
||||
const Icon = c.icon
|
||||
const num = String(i + 1).padStart(2, '0')
|
||||
return (
|
||||
<div key={i} style={{
|
||||
border: `1px solid ${COLORS.violet200}`,
|
||||
borderRadius: '4pt',
|
||||
background: `linear-gradient(160deg, ${COLORS.violet50} 0%, #ffffff 55%)`,
|
||||
padding: '4mm 4mm 3.5mm',
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
||||
boxShadow: '0 4px 12px rgba(124,58,237,0.08)',
|
||||
WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact',
|
||||
}}>
|
||||
<div style={{
|
||||
width: '14mm', height: '14mm', borderRadius: '3pt',
|
||||
background: `linear-gradient(135deg, ${COLORS.violet400} 0%, ${COLORS.violet600} 100%)`,
|
||||
boxShadow: '0 4px 10px rgba(124,58,237,0.28)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: '#ffffff', flexShrink: 0, marginBottom: '2.5mm',
|
||||
WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact',
|
||||
}}>
|
||||
<Icon size={24} strokeWidth={1.6} />
|
||||
</div>
|
||||
<div style={{ fontFamily: MONO, fontSize: '6.5pt', fontWeight: 700, color: COLORS.violet600, textTransform: 'uppercase', letterSpacing: '0.22em', marginBottom: '0.5mm' }}>{num}</div>
|
||||
<div style={{ fontSize: '12pt', fontWeight: 800, color: COLORS.slate900, lineHeight: 1.1, letterSpacing: '-0.005em', textAlign: 'center' }}>{c.name}</div>
|
||||
<div style={{ fontSize: '7pt', fontStyle: 'italic', color: COLORS.slate500, lineHeight: 1.3, textAlign: 'center', marginTop: '0.8mm', marginBottom: '2.5mm' }}>{c.blurb}</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '1.2mm', justifyContent: 'center', marginTop: 'auto' }}>
|
||||
{c.items.map((it, j) => (
|
||||
<span key={j} style={{
|
||||
fontFamily: MONO, fontSize: '7.5pt', fontWeight: 600,
|
||||
color: COLORS.slate700,
|
||||
background: COLORS.violet50,
|
||||
border: `1px solid ${COLORS.violet200}`,
|
||||
borderRadius: '999px',
|
||||
padding: '0.8mm 2mm',
|
||||
lineHeight: 1.15,
|
||||
WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact',
|
||||
}}>{it}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
/* ====================================================================== */
|
||||
/* ANHANG DIVIDER (moved from PrintAnnexSlides.tsx) */
|
||||
/* ====================================================================== */
|
||||
|
||||
export function PrintAnnexDividerPage({ lang, pageNum, totalPages, versionName }: SlideBase) {
|
||||
const de = lang === 'de'
|
||||
|
||||
const sections: [string, string, string][] = de ? [
|
||||
['18', 'Go-to-Market Strategie', 'Pilot · Skalierung · Expansion'],
|
||||
['19', 'Kennzahlen 2026 → 2030', '8 KPIs Trajektorie · Base-Case'],
|
||||
['20', 'Finanzplan', 'P&L 2026–2030 · KPI-Dashboard'],
|
||||
['21', 'P&L Detail', 'Annualisierte Gewinn-/Verlust-Rechnung'],
|
||||
['22', 'Treibervariablen', 'Annahmen + Sensitivitätsszenarien'],
|
||||
['23', 'Regulatorische Details', 'DSGVO · AI Act · NIS-2 · CRA · MaschVO'],
|
||||
['24', 'Systemarchitektur', '3 Tiers · LiteLLM Gateway · lokale Inferenz'],
|
||||
['25', 'Engineering Deep Dive', '500K+ LoC · 45 Container · 100 % Self-Hosted'],
|
||||
['26', 'Tech-Stack', '8 Kategorien · polyglott · Open Source'],
|
||||
['27', 'KI-Pipeline', 'RAG · Multi-Agent · Document Intelligence · QA'],
|
||||
['28', 'Risiken & Mitigation', '10 Risiken in 5 Kategorien'],
|
||||
['29', 'Glossar', '30 Begriffe · Compliance · Engineering · Recht'],
|
||||
] : [
|
||||
['18', 'Go-to-Market Strategy', 'Pilot · Scale · Expansion'],
|
||||
['19', 'KPIs 2026 → 2030', '8 KPI trajectory · base case'],
|
||||
['20', 'Financial Plan', 'P&L 2026–2030 · KPI dashboard'],
|
||||
['21', 'P&L Detail', 'Annualized profit & loss'],
|
||||
['22', 'Driver Variables', 'Assumptions + sensitivity scenarios'],
|
||||
['23', 'Regulatory Details', 'GDPR · AI Act · NIS-2 · CRA · Machinery Reg.'],
|
||||
['24', 'System Architecture', '3 tiers · LiteLLM gateway · local inference'],
|
||||
['25', 'Engineering Deep Dive', '500K+ LoC · 45 containers · 100 % self-hosted'],
|
||||
['26', 'Tech Stack', '8 categories · polyglot · open source'],
|
||||
['27', 'AI Pipeline', 'RAG · multi-agent · document intelligence · QA'],
|
||||
['28', 'Risks & Mitigation', '10 risks across 5 categories'],
|
||||
['29', 'Glossary', '30 terms · compliance · engineering · law'],
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="print-page-break">
|
||||
<div className="print-page print-page-bg" style={{
|
||||
width: '297mm', height: '210mm', color: COLORS.slate900,
|
||||
fontFamily: "'Inter', system-ui, sans-serif", boxSizing: 'border-box',
|
||||
padding: '14mm 18mm', margin: '0 auto 24px',
|
||||
boxShadow: '0 4px 24px rgba(59,26,122,0.10)', overflow: 'hidden',
|
||||
WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact',
|
||||
display: 'flex', flexDirection: 'column',
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderBottom: `1px solid ${COLORS.slate200}`, paddingBottom: '3mm' }}>
|
||||
<span style={{ fontFamily: MONO, fontSize: '7.5pt', fontWeight: 700, color: COLORS.violet600, textTransform: 'uppercase', letterSpacing: '0.22em' }}>{de ? 'Teil II · Anhang' : 'Part II · Appendix'}</span>
|
||||
<span style={{ fontFamily: MONO, fontSize: '7pt', color: COLORS.slate500, letterSpacing: '0.16em', textTransform: 'uppercase', fontWeight: 700 }}>BreakPilot · ComplAI</span>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', maxWidth: '260mm' }}>
|
||||
<div style={{ fontFamily: MONO, fontSize: '10pt', fontWeight: 700, color: COLORS.violet600, textTransform: 'uppercase', letterSpacing: '0.3em', marginBottom: '5mm' }}>
|
||||
{de ? '17 · Kapitelwechsel' : '17 · Chapter break'}
|
||||
</div>
|
||||
<h1 style={{ fontSize: '64pt', fontWeight: 800, color: COLORS.slate900, lineHeight: 0.95, letterSpacing: '-0.035em', margin: 0 }}>
|
||||
{de ? 'Anhang' : 'Appendix'}<span style={{ color: COLORS.violet600 }}>.</span>
|
||||
</h1>
|
||||
<div style={{ height: '3px', width: '60mm', background: `linear-gradient(90deg, ${COLORS.violet700} 0%, ${COLORS.violet400} 50%, ${COLORS.violet700} 100%)`, marginTop: '5mm', marginBottom: '6mm', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }} />
|
||||
<p style={{ fontSize: '13pt', fontWeight: 500, color: COLORS.slate700, lineHeight: 1.3, margin: 0, letterSpacing: '-0.008em', maxWidth: '230mm' }}>
|
||||
{de
|
||||
? 'Detailangaben & Belege. Was wir in der Pitch gesagt haben, mit Quellen, Zahlen und Architektur belegt.'
|
||||
: 'Detail & evidence. Everything we claimed in the pitch, backed by sources, numbers and architecture.'}
|
||||
</p>
|
||||
|
||||
<div style={{ marginTop: '8mm' }}>
|
||||
<div style={{ fontFamily: MONO, fontSize: '8pt', fontWeight: 700, color: COLORS.slate500, textTransform: 'uppercase', letterSpacing: '0.18em', marginBottom: '4mm' }}>
|
||||
{de ? 'Auf den folgenden Seiten' : 'On the following pages'}
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '4mm 8mm' }}>
|
||||
{sections.map(([n, t, sub]) => (
|
||||
<div key={n} style={{ display: 'flex', alignItems: 'flex-start', gap: '4mm' }}>
|
||||
<span style={{ fontFamily: MONO, fontSize: '13pt', fontWeight: 800, color: COLORS.violet600, lineHeight: 1, fontVariantNumeric: 'tabular-nums', letterSpacing: '-0.02em', minWidth: '11mm' }}>{n}</span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: '9.5pt', fontWeight: 700, color: COLORS.slate900, lineHeight: 1.2 }}>{t}</div>
|
||||
<div style={{ fontSize: '7pt', color: COLORS.slate600, marginTop: '0.5mm', lineHeight: 1.35 }}>{sub}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="print-mono" style={{ fontFamily: MONO, paddingTop: '3mm', borderTop: `1px solid ${COLORS.slate200}`, display: 'flex', alignItems: 'center', justifyContent: 'space-between', fontSize: '7pt', color: COLORS.slate500, letterSpacing: '0.16em', textTransform: 'uppercase', fontWeight: 700 }}>
|
||||
<span>BreakPilot · ComplAI</span>
|
||||
<span style={{ color: COLORS.violet600 }}>{versionName}</span>
|
||||
<span style={{ fontVariantNumeric: 'tabular-nums' }}>{String(pageNum).padStart(2, '0')} / {String(totalPages).padStart(2, '0')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
import { Language, PitchProduct } from '@/lib/types'
|
||||
import { Page, Bullets, Callout, COLORS } from './PrintLayout'
|
||||
import { LoopDiagram } from './PrintDiagrams'
|
||||
import { getDetails } from '@/components/slides/USPSlide.data'
|
||||
import {
|
||||
ScanLine, ShieldCheck, FileText, ClipboardCheck, Users, UserCheck,
|
||||
AlertTriangle, Brain, Target, GraduationCap, TrendingUp, MessageSquare,
|
||||
Shield, Layers, Globe, FileSearch, Sparkles, Repeat, ArrowLeftRight, Infinity,
|
||||
Lock, Heart, Banknote, ShoppingCart, Wifi, BookOpen, Landmark, Building2,
|
||||
Factory, Cpu, CheckCircle2, Zap,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
|
||||
interface SlideBase { lang: Language; pageNum: number; totalPages: number; versionName: string }
|
||||
|
||||
const USP_ICON: Record<string, LucideIcon> = {
|
||||
rfq: FileSearch, process: ClipboardCheck, bidir: ArrowLeftRight, cont: Repeat,
|
||||
trace: Layers, engine: Sparkles, opt: TrendingUp, stack: Globe, hub: Infinity,
|
||||
}
|
||||
|
||||
/* ===== USP, PAGE 1 (4 pillars) ===== */
|
||||
|
||||
export function PrintUSPPage1({ lang, pageNum, totalPages, versionName }: SlideBase) {
|
||||
const de = lang === 'de'
|
||||
const d = getDetails(de)
|
||||
const pillars = ['rfq', 'process', 'bidir', 'cont'] as const
|
||||
|
||||
return (
|
||||
<Page kicker="05" section={de ? 'USP · 1 / 2' : 'USP · 1 / 2'} title={de ? 'Compliance ↔ Code, immer in Sync.' : 'Compliance ↔ Code, always in sync.'} subtitle={de ? 'Vier Säulen, die kein anderer Anbieter geschlossen liefert: RFQ-Prüfung, Prozess-Compliance, bidirektionale Sync, kontinuierliche Engine.' : 'Four pillars no other vendor delivers end-to-end: RFQ verification, process compliance, bidirectional sync, continuous engine.'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '5mm', flex: 1, minHeight: 0 }}>
|
||||
{pillars.map((k, i) => {
|
||||
const p = d[k]
|
||||
const Icon = USP_ICON[k]
|
||||
return (
|
||||
<div key={k} style={{ border: `1px solid ${COLORS.slate200}`, borderTop: `3px solid ${COLORS.violet600}`, padding: '3mm 4mm', display: 'flex', flexDirection: 'column', minHeight: 0, overflow: 'hidden', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
{/* Header: icon + title inline, kicker top-right */}
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '3mm', marginBottom: '2mm' }}>
|
||||
<div style={{ width: '9mm', height: '9mm', flexShrink: 0, background: COLORS.violet50, display: 'flex', alignItems: 'center', justifyContent: 'center', color: COLORS.violet600, borderRadius: '2pt', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
{Icon && <Icon size={18} strokeWidth={1.5} />}
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: '2mm' }}>
|
||||
<span style={{ fontFamily: "'JetBrains Mono', ui-monospace, monospace", fontSize: '6.5pt', fontWeight: 700, color: COLORS.violet600, textTransform: 'uppercase', letterSpacing: '0.16em' }}>{p.kicker}</span>
|
||||
<span style={{ fontFamily: "'JetBrains Mono', ui-monospace, monospace", fontSize: '6.5pt', color: COLORS.slate400, fontVariantNumeric: 'tabular-nums' }}>{String(i + 1).padStart(2, '0')} / 04</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '12pt', fontWeight: 700, color: COLORS.slate900, lineHeight: 1.15, letterSpacing: '-0.005em', marginTop: '1mm' }}>{p.title}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: '8pt', color: COLORS.slate700, lineHeight: 1.45, marginBottom: '2mm' }}>{p.body}</div>
|
||||
{p.bullets && <Bullets dense items={p.bullets} />}
|
||||
{p.stat && (
|
||||
<div style={{ marginTop: 'auto', paddingTop: '2mm', borderTop: `1px solid ${COLORS.slate200}`, display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: '3mm' }}>
|
||||
<span style={{ fontFamily: "'JetBrains Mono', ui-monospace, monospace", fontSize: '6.5pt', color: COLORS.slate500, textTransform: 'uppercase', letterSpacing: '0.12em', fontWeight: 700 }}>{p.stat.k}</span>
|
||||
<span style={{ fontSize: '11pt', fontWeight: 800, color: COLORS.emerald700, fontVariantNumeric: 'tabular-nums', whiteSpace: 'nowrap' }}>{p.stat.v}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
/* ===== USP, PAGE 2 — just the closing loop (was: 4 cards + loop) =====
|
||||
*
|
||||
* The 4 under-the-hood cards moved to the dedicated Differentiators slide
|
||||
* (PrintDifferentiatorsPage). This page is now a hero "Compliance ↔ Code
|
||||
* always in sync" closing card for the USP block.
|
||||
*/
|
||||
|
||||
export function PrintUSPPage2({ lang, pageNum, totalPages, versionName }: SlideBase) {
|
||||
const de = lang === 'de'
|
||||
const d = getDetails(de)
|
||||
const MONO = "'JetBrains Mono', ui-monospace, monospace"
|
||||
|
||||
return (
|
||||
<Page kicker="05" section={de ? 'USP · 2 / 2' : 'USP · 2 / 2'} title={de ? 'Compliance ↔ Code, immer in Sync.' : 'Compliance ↔ Code, always in sync.'} subtitle={de ? 'Eine geschlossene Schleife: jede Policy-Änderung fliesst in den Code; jede Code-Änderung in die Policy zurück. Zero Drift, eine Quelle der Wahrheit.' : 'A closed loop: every policy change flows into code; every code change flows back into policy. Zero drift, one source of truth.'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
|
||||
|
||||
{/* Full-page hero loop diagram */}
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
|
||||
<div style={{ border: `1px solid ${COLORS.violet300}`, background: `linear-gradient(135deg, ${COLORS.violet50} 0%, #ffffff 50%, ${COLORS.violet50} 100%)`, borderRadius: '6pt', padding: '12mm 14mm', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '4mm', marginBottom: '6mm' }}>
|
||||
<div style={{ width: '12mm', height: '12mm', borderRadius: '50%', background: COLORS.violet600, color: '#ffffff', display: 'flex', alignItems: 'center', justifyContent: 'center', boxShadow: `0 0 0 4px ${COLORS.violet50}`, WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
<Infinity size={22} strokeWidth={2} />
|
||||
</div>
|
||||
<span style={{ fontFamily: MONO, fontSize: '9pt', fontWeight: 700, color: COLORS.violet700, textTransform: 'uppercase', letterSpacing: '0.24em' }}>{de ? 'Die Schleife · Always in Sync' : 'The Loop · Always in Sync'}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '18pt', fontWeight: 800, color: COLORS.slate900, marginBottom: '4mm', lineHeight: 1.15, letterSpacing: '-0.01em', maxWidth: '210mm' }}>{d.hub.title}</div>
|
||||
<div style={{ fontSize: '10pt', color: COLORS.slate700, lineHeight: 1.55, marginBottom: '6mm', maxWidth: '220mm' }}>{d.hub.body}</div>
|
||||
|
||||
<div style={{ background: '#ffffff', border: `1px solid ${COLORS.violet200}`, borderRadius: '3pt', padding: '5mm 6mm', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
<LoopDiagram lang={lang} />
|
||||
</div>
|
||||
|
||||
{d.hub.bullets && (
|
||||
<div style={{ marginTop: '5mm' }}>
|
||||
<Bullets dense tone="accent" items={d.hub.bullets} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
/* ===== REGULATORY LANDSCAPE ===== */
|
||||
|
||||
const CATEGORY_ICONS: LucideIcon[] = [Lock, Shield, Brain, Globe, ShieldCheck, Banknote, Heart, Users]
|
||||
const RL_CATEGORIES_DE = [
|
||||
{ name: 'Datenschutz', sample: 'DSGVO · ePrivacy · TTDSG · BDSG', count: 32 },
|
||||
{ name: 'Cybersicherheit', sample: 'NIS2 · IT-SiG · BSIG · KRITIS-VO', count: 47 },
|
||||
{ name: 'KI-Regulierung', sample: 'AI Act · KI-Haftungsrichtlinie', count: 18 },
|
||||
{ name: 'Digitale Märkte', sample: 'DMA · DSA · Data Act · DGA', count: 24 },
|
||||
{ name: 'Produktsicherheit', sample: 'CRA · MaschinenVO · ProdSG', count: 41 },
|
||||
{ name: 'Finanzregulierung', sample: 'DORA · MiCA · FinmadiG · KWG', count: 53 },
|
||||
{ name: 'Gesundheitsdaten', sample: 'MDR · IVDR · PatDG · KHG', count: 28 },
|
||||
{ name: 'Verbraucherschutz', sample: 'UWG · BGB · GeschGehG · HinSchG', count: 36 },
|
||||
]
|
||||
const RL_CATEGORIES_EN = [
|
||||
{ name: 'Data Privacy', sample: 'GDPR · ePrivacy · TTDSG · BDSG', count: 32 },
|
||||
{ name: 'Cybersecurity', sample: 'NIS2 · IT-SecAct · BSIG · KRITIS', count: 47 },
|
||||
{ name: 'AI Regulation', sample: 'AI Act · AI Liability Directive', count: 18 },
|
||||
{ name: 'Digital Markets', sample: 'DMA · DSA · Data Act · DGA', count: 24 },
|
||||
{ name: 'Product Safety', sample: 'CRA · Machinery Reg. · ProdSG', count: 41 },
|
||||
{ name: 'Financial Reg.', sample: 'DORA · MiCA · FinmadiG · KWG', count: 53 },
|
||||
{ name: 'Health Data', sample: 'MDR · IVDR · PatDG · Hospital Act', count: 28 },
|
||||
{ name: 'Consumer Prot.', sample: 'UWG · BGB · Trade Secrets · HinSchG', count: 36 },
|
||||
]
|
||||
const INDUSTRY_ICONS: LucideIcon[] = [Building2, Factory, Heart, Banknote, ShoppingCart, Cpu, Wifi, Brain, ShieldCheck, BookOpen, Landmark]
|
||||
const INDUSTRIES_DE = ['Alle Unternehmen', 'Maschinenbau', 'Gesundheit', 'Finanzsektor', 'E-Commerce', 'Technologie', 'IoT / Hardware', 'KI-Anbieter', 'Krit. Infrastruktur', 'Medien', 'Öffentl. Sektor']
|
||||
const INDUSTRIES_EN = ['All companies', 'Manufacturing', 'Healthcare', 'Finance', 'E-Commerce', 'Technology', 'IoT / Hardware', 'AI Providers', 'Critical Infra.', 'Media', 'Public Sector']
|
||||
|
||||
export function PrintRegulatoryLandscapePage({ lang, pageNum, totalPages, versionName }: SlideBase) {
|
||||
const de = lang === 'de'
|
||||
const cats = de ? RL_CATEGORIES_DE : RL_CATEGORIES_EN
|
||||
const industries = de ? INDUSTRIES_DE : INDUSTRIES_EN
|
||||
|
||||
return (
|
||||
<Page kicker="06" section={de ? 'REGULATORISCHE LANDSCHAFT' : 'REGULATORY LANDSCAPE'} title={de ? '380+ Originaldokumente. 10 Branchen. Eine Plattform.' : '380+ original documents. 10 industries. One platform.'} subtitle={de ? 'Vollständiger RAG-Index aller relevanten EU- und DACH-Regulierungen, kontinuierlich aktualisiert, semantisch durchsuchbar.' : 'Complete RAG index of all relevant EU and DACH regulations, continuously updated, semantically searchable.'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
|
||||
|
||||
{/* KPI strip */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '4mm', padding: '4mm 0', borderTop: `1px solid ${COLORS.slate200}`, borderBottom: `1px solid ${COLORS.slate200}` }}>
|
||||
{[
|
||||
{ n: '380+', l: de ? 'Originaldokumente' : 'Original documents' },
|
||||
{ n: '25.000+', l: de ? 'Extrahierte Controls' : 'Extracted controls' },
|
||||
{ n: '8', l: de ? 'Regulierungs-Kategorien' : 'Regulatory categories' },
|
||||
{ n: '10', l: de ? 'Branchen-Profile' : 'Industry profiles' },
|
||||
].map((k, i) => (
|
||||
<div key={i}>
|
||||
<div style={{ fontSize: '22pt', fontWeight: 800, color: COLORS.indigo600, lineHeight: 1, fontVariantNumeric: 'tabular-nums' }}>{k.n}</div>
|
||||
<div style={{ fontSize: '7.5pt', color: COLORS.slate500, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.08em', marginTop: '1.5mm' }}>{k.l}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '5mm', flex: 1, minHeight: 0, display: 'grid', gridTemplateColumns: '1.55fr 1fr', gap: '6mm' }}>
|
||||
{/* Categories — 2x4 card grid with icons */}
|
||||
<div>
|
||||
<div style={{ fontSize: '8pt', fontWeight: 700, color: COLORS.slate500, textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '2mm' }}>{de ? 'Acht Regulierungs-Kategorien' : 'Eight regulatory categories'}</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '2mm' }}>
|
||||
{cats.map((c, i) => {
|
||||
const CIcon = CATEGORY_ICONS[i]
|
||||
return (
|
||||
<div key={i} style={{ border: `1px solid ${COLORS.slate200}`, padding: '2mm 2.5mm', display: 'flex', alignItems: 'flex-start', gap: '2mm', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
<div style={{ width: '8mm', height: '8mm', background: COLORS.violet50, display: 'flex', alignItems: 'center', justifyContent: 'center', color: COLORS.violet600, flexShrink: 0, WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}><CIcon size={14} strokeWidth={1.6} /></div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: '2mm' }}>
|
||||
<div style={{ fontSize: '8.5pt', fontWeight: 700, color: COLORS.slate900, lineHeight: 1.2 }}>{c.name}</div>
|
||||
<div style={{ fontFamily: "'JetBrains Mono', ui-monospace, monospace", fontSize: '7pt', fontWeight: 700, color: COLORS.violet600, fontVariantNumeric: 'tabular-nums' }}>{c.count}</div>
|
||||
</div>
|
||||
<div style={{ fontSize: '6.5pt', color: COLORS.slate500, lineHeight: 1.3, marginTop: '0.5mm' }}>{c.sample}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Industries — cards with icons */}
|
||||
<div>
|
||||
<div style={{ fontSize: '8pt', fontWeight: 700, color: COLORS.slate500, textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '2mm' }}>{de ? 'Zehn Branchen-Profile' : 'Ten industry profiles'}</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '2mm' }}>
|
||||
{industries.map((ind, i) => {
|
||||
const IIcon = INDUSTRY_ICONS[i]
|
||||
const isFocus = i === 1
|
||||
return (
|
||||
<div key={i} style={{ border: `1px solid ${isFocus ? COLORS.violet300 : COLORS.slate200}`, background: isFocus ? COLORS.violet50 : '#ffffff', padding: '2mm 2.5mm', display: 'flex', alignItems: 'center', gap: '2mm', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
<div style={{ width: '6mm', height: '6mm', display: 'flex', alignItems: 'center', justifyContent: 'center', color: isFocus ? COLORS.violet700 : COLORS.slate500, flexShrink: 0 }}><IIcon size={12} strokeWidth={1.7} /></div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{isFocus && <div style={{ fontSize: '6pt', color: COLORS.violet700, textTransform: 'uppercase', letterSpacing: '0.1em', fontWeight: 700, lineHeight: 1 }}>{de ? 'Kernfokus' : 'Core focus'}</div>}
|
||||
<div style={{ fontSize: '8pt', color: COLORS.slate800, fontWeight: isFocus ? 700 : 500, lineHeight: 1.2, marginTop: isFocus ? '0.5mm' : 0 }}>{ind}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '4mm', flexShrink: 0 }}>
|
||||
<Callout tone="accent" label={de ? 'Was das bedeutet' : 'What this means'}>
|
||||
{de
|
||||
? 'Ein RAG-Index für alle EU- und DACH-Regulierungen, semantisch durchsuchbar, kontinuierlich aktualisiert. Kunden fragen einmal, die Plattform antwortet aus allen Gesetzen gleichzeitig.'
|
||||
: 'One RAG index for all EU and DACH regulations, semantically searchable, continuously updated. Customers ask once, the platform answers from all laws simultaneously.'}
|
||||
</Callout>
|
||||
</div>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
/* ===== PRODUCT / MODULAR TOOLKIT ===== */
|
||||
|
||||
const MODULE_ICONS: LucideIcon[] = [ScanLine, ShieldCheck, FileText, ClipboardCheck, Users, UserCheck, AlertTriangle, Brain, Target, GraduationCap, TrendingUp, MessageSquare]
|
||||
const MODULES_FULL_DE = [
|
||||
{ name: 'Code Security', desc: 'SAST · DAST · SBOM · Container · Secrets · Pentesting', features: ['Bei jedem Push', 'Auto-Fix LLM', 'CI/CD-integriert'] },
|
||||
{ name: 'CE-SW-Risikobeurteilung', desc: 'CE-Kennzeichnung für Maschinen mit Software-Anteil', features: ['Maschinen-VO', 'CRA-konform', 'Code-Basis-Analyse'] },
|
||||
{ name: 'Compliance-Dokumente', desc: 'VVT (Art. 30) · TOMs · DSFA (Art. 35) · Löschkonzept', features: ['Auto-Generiert', 'Versionsverlauf', 'Audit-tauglich'] },
|
||||
{ name: 'Audit Manager', desc: 'Abweichungen End-to-End: Rollen · Stichtage · Eskalation', features: ['Tickets + Nachweise', 'GF-Eskalation', 'Compliance-SLA'] },
|
||||
{ name: 'DSR / Betroffenenrechte', desc: 'Auskunft, Berichtigung, Löschung, Datenübertragbarkeit', features: ['Self-Service', 'Identitätsprüfung', 'Frist-Tracking'] },
|
||||
{ name: 'Consent', desc: 'Einwilligungs-Management, Cookie-Banner, ePrivacy', features: ['CMP integriert', 'Audit-Log', 'Multi-Tenant'] },
|
||||
{ name: 'Incident Response', desc: 'Vorfälle, Meldung (72h), Mitigation, Forensik', features: ['Art. 33/34 DSGVO', 'BSI-Meldepfade', 'Forensik-Hooks'] },
|
||||
{ name: 'Compliance LLM', desc: 'GPT für Text + Audio, EU-gehostet, mit Quellenangabe', features: ['Self-Hosted', 'EU-souverän', 'Audit-zitierbar'] },
|
||||
{ name: 'Tender Matching', desc: 'RFQ-Antworten automatisch gegen Codebase + Policies', features: ['Stunden statt Wochen', 'Win-ready', 'Klausel-Mapping'] },
|
||||
{ name: 'Academy', desc: 'Online-Schulungen für Geschäftsführung und Mitarbeiter', features: ['Mandatory Training', 'Zertifikate', 'GF-Pflicht erfüllt'] },
|
||||
{ name: 'Compliance Optimizer', desc: 'Maximale KI-Nutzung im legalen Rahmen, ersetzt 20-200k € Anwaltskosten', features: ['ROI-ranking', 'Sweet-Spot', 'Risikobalance'] },
|
||||
{ name: 'Kommunikation', desc: 'Chat (Matrix) + Video (Jitsi) + KI-Support', features: ['Self-Hosted', 'EU-Hosting', 'Audit-Logs'] },
|
||||
]
|
||||
const MODULES_FULL_EN = [
|
||||
{ name: 'Code Security', desc: 'SAST · DAST · SBOM · Container · Secrets · Pentesting', features: ['Every push', 'Auto-fix LLM', 'CI/CD integrated'] },
|
||||
{ name: 'CE SW Risk Assessment', desc: 'CE marking for machinery with software', features: ['Machinery Reg.', 'CRA-compliant', 'Code-level analysis'] },
|
||||
{ name: 'Compliance Documents', desc: 'RoPA (Art. 30) · TOMs · DPIA (Art. 35) · Retention', features: ['Auto-generated', 'Version history', 'Audit-ready'] },
|
||||
{ name: 'Audit Manager', desc: 'Deviations end-to-end: roles · deadlines · escalation', features: ['Tickets + evidence', 'Mgmt escalation', 'Compliance SLA'] },
|
||||
{ name: 'DSR / Data Subject Rights', desc: 'Access, rectification, erasure, portability', features: ['Self-service', 'Identity check', 'Deadline tracking'] },
|
||||
{ name: 'Consent', desc: 'Consent mgmt, cookie banner, ePrivacy', features: ['CMP integrated', 'Audit log', 'Multi-tenant'] },
|
||||
{ name: 'Incident Response', desc: 'Breaches, reporting (72h), mitigation, forensics', features: ['GDPR Art. 33/34', 'BSI channels', 'Forensic hooks'] },
|
||||
{ name: 'Compliance LLM', desc: 'GPT for text + audio, EU-hosted, with citations', features: ['Self-hosted', 'EU-sovereign', 'Audit-citable'] },
|
||||
{ name: 'Tender Matching', desc: 'RFQ answers automatically against codebase + policies', features: ['Hours not weeks', 'Win-ready', 'Clause mapping'] },
|
||||
{ name: 'Academy', desc: 'Online training for management and staff', features: ['Mandatory training', 'Certificates', 'Mgmt duties fulfilled'] },
|
||||
{ name: 'Compliance Optimizer', desc: 'Max AI use within legal limits, replaces €20-200k legal fees', features: ['ROI ranking', 'Sweet spot', 'Risk balance'] },
|
||||
{ name: 'Communication', desc: 'Chat (Matrix) + video (Jitsi) + AI support', features: ['Self-hosted', 'EU hosting', 'Audit logs'] },
|
||||
]
|
||||
|
||||
export function PrintProductPage({ products, lang, pageNum, totalPages, versionName }: SlideBase & { products: PitchProduct[] }) {
|
||||
void products
|
||||
const de = lang === 'de'
|
||||
const modules = de ? MODULES_FULL_DE : MODULES_FULL_EN
|
||||
return (
|
||||
<Page kicker="07" section={de ? 'MODULARER BAUKASTEN' : 'MODULAR TOOLKIT'} title={de ? '12 Module, einzeln oder als Bundle.' : '12 modules, individual or bundled.'} subtitle={de ? 'Kunden wählen die Module, die sie brauchen. Jedes Modul liefert eigene Compliance-Werte; die Plattform integriert sie zu einem Audit-Trail.' : 'Customers pick the modules they need. Each module delivers compliance value on its own; the platform unifies them into a single audit trail.'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '4mm', flex: 1, minHeight: 0 }}>
|
||||
{modules.map((m, i) => {
|
||||
const Icon = MODULE_ICONS[i]
|
||||
return (
|
||||
<div key={i} style={{ border: `1px solid ${COLORS.slate200}`, borderTop: `2px solid ${COLORS.indigo600}`, padding: '3mm 4mm', display: 'flex', flexDirection: 'column', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '3mm', marginBottom: '1.5mm' }}>
|
||||
<div style={{ width: '8mm', height: '8mm', background: COLORS.indigo50, display: 'flex', alignItems: 'center', justifyContent: 'center', color: COLORS.indigo600, flexShrink: 0, WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
<Icon size={16} strokeWidth={1.5} />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0, fontSize: '10pt', fontWeight: 700, color: COLORS.slate900, lineHeight: 1.15 }}>{m.name}</div>
|
||||
<div style={{ fontSize: '7pt', color: COLORS.slate400, fontVariantNumeric: 'tabular-nums' }}>{String(i + 1).padStart(2, '0')}</div>
|
||||
</div>
|
||||
<div style={{ fontSize: '8pt', color: COLORS.slate600, lineHeight: 1.4, marginBottom: '2mm' }}>{m.desc}</div>
|
||||
<div style={{ marginTop: 'auto', borderTop: `1px solid ${COLORS.slate100}`, paddingTop: '2mm', fontSize: '7pt', color: COLORS.slate500 }}>{m.features.join(' · ')}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '4mm', flexShrink: 0 }}>
|
||||
<Callout tone="positive" label={de ? 'Pricing-Logik' : 'Pricing logic'}>
|
||||
{de
|
||||
? 'Starter <10 MA: 3.600 €/J · Professional 10–250: 15–40k €/J · Enterprise 250+: ab 50k €/J. Mitarbeiterbasiert. Standard: BSI-Cloud DE. Optional: Mac Mini/Studio für absolute Privacy bei Kleinunternehmen.'
|
||||
: 'Starter <10 emp: €3,600/yr · Professional 10–250: €15–40k/yr · Enterprise 250+: from €50k/yr. Employee-based. Standard: BSI cloud DE. Optional: Mac Mini/Studio for absolute privacy for micro businesses.'}
|
||||
</Callout>
|
||||
</div>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
/* ===== HOW IT WORKS ===== */
|
||||
|
||||
const STEPS_DE = [
|
||||
{ n: '01', t: 'Cloud-Vertrag abschließen', d: 'BSI-zertifizierte Cloud in Deutschland. Fixe oder flexible Kosten je nach Volumen. Onboarding-Call mit dedicated CSM in der ersten Woche.' },
|
||||
{ n: '02', t: 'Code-Repos verbinden', d: 'Git-Repos, CI/CD-Pipelines und Firmware-Projekte über Standard-Integrationen anbinden. Die KI scannt automatisch, bei jeder Änderung.' },
|
||||
{ n: '03', t: 'Compliance & Security automatisieren', d: 'Kontinuierliche Code-Analyse, Pentesting und Risikoanalysen. VVT, TOMs, DSFA, CE-Dokumentation werden automatisch erstellt und aktualisiert.' },
|
||||
{ n: '04', t: 'Audit vorbereiten', d: 'Alle Nachweise, Dokumente und Risikobeurteilungen auf Knopfdruck. Abweichungen nach dem Audit automatisch nachverfolgt, Stichtage, Tickets, Eskalation.' },
|
||||
]
|
||||
const STEPS_EN = [
|
||||
{ n: '01', t: 'Sign cloud contract', d: 'BSI-certified cloud in Germany. Fixed or flexible costs depending on volume. Onboarding call with dedicated CSM in week one.' },
|
||||
{ n: '02', t: 'Connect code repos', d: 'Connect Git repos, CI/CD pipelines and firmware projects via standard integrations. The AI scans automatically, on every change.' },
|
||||
{ n: '03', t: 'Automate compliance & security', d: 'Continuous code analysis, pentesting and risk assessments. RoPA, TOMs, DPIA, CE documentation auto-generated and updated.' },
|
||||
{ n: '04', t: 'Prepare for audit', d: 'All evidence, documents and risk assessments at the push of a button. Post-audit deviations automatically tracked, deadlines, tickets, escalation.' },
|
||||
]
|
||||
|
||||
const TIMELINE_DAYS_DE = ['Tag 0', 'Tag 3', 'Tag 7', 'Tag 14', 'Tag 30']
|
||||
const TIMELINE_DAYS_EN = ['Day 0', 'Day 3', 'Day 7', 'Day 14', 'Day 30']
|
||||
const TIMELINE_LABELS_DE = ['Vertrag', 'Onboarding-Call', 'Repos angebunden', 'VVT/TOMs auto', 'audit-ready']
|
||||
const TIMELINE_LABELS_EN = ['Contract', 'Onboarding call', 'Repos connected', 'RoPA/TOMs auto', 'audit-ready']
|
||||
|
||||
export function PrintHowItWorksPage({ lang, pageNum, totalPages, versionName }: SlideBase) {
|
||||
const de = lang === 'de'
|
||||
const steps = de ? STEPS_DE : STEPS_EN
|
||||
const days = de ? TIMELINE_DAYS_DE : TIMELINE_DAYS_EN
|
||||
const labels = de ? TIMELINE_LABELS_DE : TIMELINE_LABELS_EN
|
||||
const MONO = "'JetBrains Mono', ui-monospace, monospace"
|
||||
|
||||
return (
|
||||
<Page kicker="08" section={de ? 'SO FUNKTIONIERT\'S' : 'HOW IT WORKS'} title={de ? 'In 4 Schritten zur kontinuierlichen Compliance.' : 'Continuous compliance in 4 steps.'} subtitle={de ? 'Vom Vertrag bis zur Audit-Bereitschaft in der Regel <30 Tage. Kein Excel, kein Pentest-Vendor, keine manuelle Dokumentenpflege.' : 'From contract to audit-ready typically in <30 days. No Excel, no pentest vendor, no manual document maintenance.'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
|
||||
|
||||
{/* 4-step rail: numbered violet circles on a horizontal connector line */}
|
||||
<div style={{ position: 'relative', marginTop: '3mm', flexShrink: 0 }}>
|
||||
<div style={{ position: 'absolute', top: '6mm', left: '6mm', right: '6mm', height: '2px', background: `linear-gradient(90deg, ${COLORS.violet600} 0%, ${COLORS.violet400} 100%)`, opacity: 0.85, WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }} />
|
||||
<div style={{ position: 'relative', display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '5mm' }}>
|
||||
{steps.map((s, i) => (
|
||||
<div key={i} style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start' }}>
|
||||
<div style={{ width: '12mm', height: '12mm', borderRadius: '50%', background: COLORS.violet600, color: '#ffffff', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '12pt', fontWeight: 800, letterSpacing: '-0.01em', boxShadow: `0 0 0 4px ${COLORS.violet50}`, WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>{s.n}</div>
|
||||
<div style={{ marginTop: '2.5mm', fontSize: '11pt', fontWeight: 700, color: COLORS.slate900, lineHeight: 1.15, letterSpacing: '-0.005em' }}>{s.t}</div>
|
||||
<div style={{ marginTop: '1.5mm', fontSize: '8pt', color: COLORS.slate700, lineHeight: 1.45 }}>{s.d}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Day-marker timeline — directly under steps, ~3mm gap */}
|
||||
<div style={{ flexShrink: 0, marginTop: '3mm' }}>
|
||||
<div style={{ position: 'relative', height: '13mm' }}>
|
||||
<div style={{ position: 'absolute', left: '7mm', right: '7mm', top: '5mm', height: '1.5px', background: `repeating-linear-gradient(90deg, ${COLORS.violet400} 0, ${COLORS.violet400} 2mm, transparent 2mm, transparent 4mm)`, WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }} />
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', height: '100%' }}>
|
||||
{days.map((d, i) => (
|
||||
<div key={i} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', position: 'relative' }}>
|
||||
<div style={{ fontFamily: MONO, fontSize: '7pt', fontWeight: 700, color: COLORS.violet700, background: COLORS.violet50, padding: '1mm 3mm', borderRadius: '99pt', border: `1px solid ${COLORS.violet300}`, WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>{d}</div>
|
||||
<div style={{ width: '3mm', height: '3mm', borderRadius: '50%', background: COLORS.violet600, marginTop: '1mm', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }} />
|
||||
<div style={{ marginTop: '1.5mm', fontSize: '7pt', color: COLORS.slate700, textAlign: 'center', fontWeight: 600 }}>{labels[i]}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time-to-value callout */}
|
||||
<div style={{ flexShrink: 0, marginTop: '3mm' }}>
|
||||
<Callout tone="accent" label={de ? 'Typische Time-to-Value' : 'Typical time-to-value'}>
|
||||
{de
|
||||
? 'Median 14 Tage · Worst Case 28 Tage. Vom Vertrag bis zur Audit-Bereitschaft typischerweise unter 30 Tagen.'
|
||||
: 'Median 14 days · worst case 28 days. From contract to audit-readiness typically under 30 days.'}
|
||||
</Callout>
|
||||
</div>
|
||||
|
||||
{/* What you get on day N — 4-column benefit block, fills bottom third */}
|
||||
<div style={{ flex: 1, minHeight: 0, marginTop: '4mm', display: 'flex', flexDirection: 'column', justifyContent: 'flex-end' }}>
|
||||
<div style={{ fontFamily: MONO, fontSize: '7pt', fontWeight: 700, color: COLORS.slate500, textTransform: 'uppercase', letterSpacing: '0.18em', marginBottom: '2mm' }}>
|
||||
{de ? 'Was Sie wann bekommen' : 'What you get, when'}
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '3mm' }}>
|
||||
{([
|
||||
[Shield, de ? 'Risikoanalysen automatisch ab Tag 3' : 'Risk analyses automatic from day 3'],
|
||||
[FileText, de ? 'VVT / TOMs / DSFA generiert ab Tag 14' : 'RoPA / TOMs / DPIA generated from day 14'],
|
||||
[CheckCircle2, de ? 'Audit-Trail vollständig ab Tag 30' : 'Audit trail complete from day 30'],
|
||||
[Zap, de ? 'Continuous Scanning bei jedem Push' : 'Continuous scanning on every push'],
|
||||
] as [LucideIcon, string][]).map(([Icon, t], i) => (
|
||||
<div key={i} style={{ border: `1px solid ${COLORS.slate200}`, borderTop: `2px solid ${COLORS.violet600}`, padding: '2.5mm 3mm', display: 'flex', alignItems: 'flex-start', gap: '2.5mm', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
<div style={{ width: '7mm', height: '7mm', background: COLORS.violet50, display: 'flex', alignItems: 'center', justifyContent: 'center', color: COLORS.violet600, flexShrink: 0, WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}><Icon size={13} strokeWidth={1.7} /></div>
|
||||
<div style={{ flex: 1, fontSize: '8pt', color: COLORS.slate800, fontWeight: 600, lineHeight: 1.35 }}>{t}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
/* ===== BUSINESS MODEL / PRICING ===== */
|
||||
|
||||
export function PrintBusinessModelPage({ lang, pageNum, totalPages, versionName }: SlideBase) {
|
||||
const de = lang === 'de'
|
||||
const MONO = "'JetBrains Mono', ui-monospace, monospace"
|
||||
|
||||
const tiers = [
|
||||
{ name: 'Starter', target: de ? '< 25 Mitarbeiter · Basis-Module' : '< 25 employees · basic modules', price: '€3.600', unit: de ? '/ Jahr' : '/ year', features: de ? ['DSGVO + Audit + DSR-Workflow', 'Compliance Scanner (CI/CD)', 'EU-Hosting · BSI C5', 'E-Mail-Support'] : ['GDPR + Audit + DSR workflow', 'Compliance Scanner (CI/CD)', 'EU hosting · BSI C5', 'Email support'], tint: COLORS.violet400, bg: COLORS.violet50, featured: false },
|
||||
{ name: 'Professional', target: de ? '25–250 Mitarbeiter · alle Module' : '25–250 employees · all modules', price: '€18.000', unit: de ? '/ Jahr' : '/ year', features: de ? ['Alle 12 Module', 'Priority-Support · Onboarding-Call', 'CE-Software-Risiko + Tender Matching', 'Dedicated CSM · 14-tägige Reviews', 'Custom-Integrationen (Jira, GitLab)'] : ['All 12 modules', 'Priority support · onboarding call', 'CE software risk + tender matching', 'Dedicated CSM · biweekly reviews', 'Custom integrations (Jira, GitLab)'], tint: COLORS.violet600, bg: `linear-gradient(180deg, ${COLORS.violet50} 0%, #ffffff 60%, ${COLORS.violet50} 100%)`, featured: true },
|
||||
{ name: 'Enterprise', target: de ? '250+ Mitarbeiter · maßgeschneidert' : '250+ employees · custom', price: de ? 'ab €50.000' : 'from €50k', unit: de ? '/ Jahr' : '/ year', features: de ? ['Alles aus Professional', 'SLA · Custom Contract', 'On-Premise / Air-Gap (Mac Mini/Studio)', 'Dedicated Customer Engineering', 'Multi-Region Audit-Trail'] : ['Everything in Professional', 'SLA · custom contract', 'On-premise / air-gap (Mac Mini/Studio)', 'Dedicated customer engineering', 'Multi-region audit trail'], tint: COLORS.amber600, bg: COLORS.amber50, featured: false },
|
||||
]
|
||||
|
||||
return (
|
||||
<Page kicker="10" section={de ? 'PREISE' : 'PRICING'} title={de ? 'Drei Tiers. Mitarbeiterbasiert. ROI ab Tag 1.' : 'Three tiers. Employee-based. ROI from day 1.'} subtitle={de ? 'Recurring Revenue · BSI-Cloud DE Standard · Optional Hardware. Kunden zahlen ~50k/Jahr und sparen €112k.' : 'Recurring revenue · BSI cloud DE standard · optional hardware. Customers pay ~€50k/yr and save €112k.'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
|
||||
|
||||
{/* 3 product cards */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '5mm', marginBottom: '5mm', position: 'relative' }}>
|
||||
{tiers.map((t) => (
|
||||
<div key={t.name} style={{ background: t.bg, border: `${t.featured ? '2px' : '1px'} solid ${t.tint}`, borderRadius: '6pt', padding: '5mm', display: 'flex', flexDirection: 'column', position: 'relative', boxShadow: t.featured ? `0 6px 18px ${COLORS.violet600}25` : 'none', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
{/* Featured badge */}
|
||||
{t.featured && (
|
||||
<div style={{ position: 'absolute', top: '-3.5mm', left: '50%', transform: 'translateX(-50%)', fontFamily: MONO, fontSize: '7pt', fontWeight: 700, color: '#ffffff', background: COLORS.violet600, padding: '1mm 4mm', borderRadius: '99pt', textTransform: 'uppercase', letterSpacing: '0.18em', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>{de ? 'Beliebt' : 'Popular'}</div>
|
||||
)}
|
||||
|
||||
<div style={{ fontFamily: MONO, fontSize: '8pt', fontWeight: 700, color: t.tint, textTransform: 'uppercase', letterSpacing: '0.22em', marginBottom: '2mm' }}>{t.name}</div>
|
||||
<div style={{ fontSize: '8.5pt', color: COLORS.slate600, marginBottom: '4mm', lineHeight: 1.4 }}>{t.target}</div>
|
||||
|
||||
{/* Price */}
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: '2mm', marginBottom: '4mm' }}>
|
||||
<div style={{ fontSize: '28pt', fontWeight: 800, color: COLORS.slate900, lineHeight: 1, fontVariantNumeric: 'tabular-nums', letterSpacing: '-0.025em' }}>{t.price}</div>
|
||||
<div style={{ fontSize: '9pt', color: COLORS.slate500, fontWeight: 500 }}>{t.unit}</div>
|
||||
</div>
|
||||
|
||||
{/* Features */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5mm' }}>
|
||||
{t.features.map((f, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'flex-start', gap: '2mm' }}>
|
||||
<span style={{ color: COLORS.emerald600, fontSize: '9pt', fontWeight: 700, marginTop: '0.5pt' }}>✓</span>
|
||||
<span style={{ fontSize: '8.5pt', color: COLORS.slate700, lineHeight: 1.4 }}>{f}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Unit economics + savings — bottom strip */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1.4fr 1fr', gap: '6mm', flex: 1, minHeight: 0 }}>
|
||||
<div>
|
||||
<div style={{ fontFamily: MONO, fontSize: '7.5pt', fontWeight: 700, color: COLORS.violet600, textTransform: 'uppercase', letterSpacing: '0.18em', marginBottom: '2mm' }}>{de ? 'Unit Economics · Reifephase' : 'Unit Economics · Mature'}</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '3mm' }}>
|
||||
{[
|
||||
{ n: '~70%', l: de ? 'Bruttomarge' : 'Gross margin', tone: 'positive' as const },
|
||||
{ n: '~3,5×', l: 'LTV / CAC', tone: 'positive' as const },
|
||||
{ n: '~14m', l: de ? 'CAC-Payback' : 'CAC payback' },
|
||||
{ n: '<8%', l: de ? 'Net Churn p.a.' : 'Net churn p.a.', tone: 'positive' as const },
|
||||
].map((k, i) => (
|
||||
<div key={i} style={{ border: `1px solid ${COLORS.slate200}`, padding: '3mm', background: '#ffffff', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
<div style={{ fontSize: '18pt', fontWeight: 800, color: k.tone === 'positive' ? COLORS.emerald700 : COLORS.slate900, lineHeight: 1, fontVariantNumeric: 'tabular-nums', letterSpacing: '-0.015em' }}>{k.n}</div>
|
||||
<div style={{ fontFamily: MONO, fontSize: '6.5pt', color: COLORS.slate500, marginTop: '2mm', textTransform: 'uppercase', letterSpacing: '0.12em', fontWeight: 700 }}>{k.l}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ background: COLORS.emerald50, border: `1px solid ${COLORS.emerald600}`, borderRadius: '4pt', padding: '3mm 4mm', display: 'flex', flexDirection: 'column', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
<div style={{ fontFamily: MONO, fontSize: '7pt', color: COLORS.emerald700, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.18em', marginBottom: '1.5mm' }}>{de ? 'Netto-Effekt · KMU 50 MA / Jahr 1' : 'Net effect · SME 50 emp. / Y1'}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: '2.5mm' }}>
|
||||
<div style={{ fontSize: '22pt', fontWeight: 800, color: COLORS.emerald700, lineHeight: 1, fontVariantNumeric: 'tabular-nums', letterSpacing: '-0.02em' }}>+€30k</div>
|
||||
<div style={{ fontSize: '8.5pt', color: COLORS.emerald700, fontWeight: 600 }}>{de ? 'pro KMU / Jahr' : 'per SME / yr'}</div>
|
||||
</div>
|
||||
<div style={{ fontSize: '7.5pt', color: COLORS.slate700, marginTop: '1.5mm', lineHeight: 1.4 }}>
|
||||
{de ? 'Kunde spart €55k, zahlt €25k. ROI ab Tag 1.' : 'Customer saves €55k, pays €25k. ROI from day 1.'}
|
||||
</div>
|
||||
|
||||
{/* Itemized breakdown */}
|
||||
<div style={{ marginTop: '2.5mm', display: 'flex', flexDirection: 'column' }}>
|
||||
{([
|
||||
[de ? 'Pentests (kontinuierlich, inkl.)' : 'Pentests (continuous, incl.)', '+€13k'],
|
||||
[de ? 'CE-Risiko (Code-basiert, inkl.)' : 'CE risk (code-based, incl.)', '+€9k'],
|
||||
[de ? 'Compliance-Zeit (−60%)' : 'Compliance time (−60%)', '+€15k'],
|
||||
[de ? 'Audit-Vorbereitung (auto)' : 'Audit prep (auto)', '+€9k'],
|
||||
[de ? 'Legal-Stunden (−40%)' : 'Legal hours (−40%)', '+€5k'],
|
||||
[de ? 'Schulungen (Academy inkl.)' : 'Training (Academy incl.)', '+€4k'],
|
||||
] as [string, string][]).map(([l, v], i, arr) => (
|
||||
<div key={i} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: '2mm', padding: '1.2mm 0', borderBottom: i < arr.length - 1 ? `0.5px solid ${COLORS.emerald600}33` : 'none' }}>
|
||||
<span style={{ fontSize: '8pt', color: COLORS.slate700, lineHeight: 1.3 }}>{l}</span>
|
||||
<span style={{ fontSize: '8.5pt', fontWeight: 700, color: COLORS.emerald700, fontVariantNumeric: 'tabular-nums', whiteSpace: 'nowrap' }}>{v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '2mm', fontSize: '7pt', color: COLORS.slate600, fontStyle: 'italic', lineHeight: 1.35 }}>
|
||||
{de
|
||||
? 'Plus Vermeidung von Bußgeldern (bis 4% Jahresumsatz) und gewonnene RFQs.'
|
||||
: 'Plus avoided fines (up to 4% annual revenue) and won RFQs.'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
PitchCompetitor, PitchFeature, PitchMilestone, PitchMetric, PitchFunding, PitchProduct,
|
||||
FpScenarioRef, FMResult, FMAssumption,
|
||||
} from '@/lib/types'
|
||||
import { finanzplanToFMResults } from '@/lib/finanzplan/adapter'
|
||||
import PrintDeck from './_components/PrintDeck'
|
||||
|
||||
interface Ctx {
|
||||
@@ -65,28 +66,39 @@ export default async function PitchPrintPage({ params, searchParams }: Ctx) {
|
||||
fp_scenarios: (map.fm_scenarios || []) as FpScenarioRef[],
|
||||
}
|
||||
|
||||
// Financial variant: fetch FM results + parse assumptions
|
||||
// Always fetch FM results + assumptions so the standard PDF can render the
|
||||
// annex-finanzplan slide. The `financial` flag only adds the extra detail
|
||||
// P&L page and the cap-table page.
|
||||
//
|
||||
// Data source: the live `fp_*` tables (same as the interactive deck), bridged
|
||||
// to FMResult[] via finanzplanToFMResults. The legacy `pitch_fm_results` table
|
||||
// is no longer populated by the current pipeline.
|
||||
let fmResults: FMResult[] = []
|
||||
let fmAssumptions: FMAssumption[] = []
|
||||
|
||||
if (financial) {
|
||||
const scenarios = (map.fm_scenarios || []) as FpScenarioRef[]
|
||||
const defaultScenario = scenarios.find(s => s.is_default) ?? scenarios[0] ?? null
|
||||
|
||||
if (defaultScenario?.id) {
|
||||
const resultsRes = await pool.query(
|
||||
`SELECT * FROM pitch_fm_results WHERE scenario_id = $1 ORDER BY month`,
|
||||
[defaultScenario.id],
|
||||
)
|
||||
fmResults = resultsRes.rows as FMResult[]
|
||||
}
|
||||
|
||||
const rawAssumptions = (map.fm_assumptions || []) as Array<Record<string, unknown>>
|
||||
fmAssumptions = rawAssumptions.map(a => ({
|
||||
...a,
|
||||
value: typeof a.value === 'string' ? JSON.parse(a.value as string) : a.value,
|
||||
})) as FMAssumption[]
|
||||
const scenarios = (map.fm_scenarios || []) as FpScenarioRef[]
|
||||
const defaultScenario = scenarios.find(s => s.is_default) ?? scenarios[0] ?? null
|
||||
// Snapshot stores fp_scenario IDs under `fm_scenarios`; fall back to the live
|
||||
// default fp scenario if the snapshot is empty (older versions).
|
||||
let scenarioId: string | null = defaultScenario?.id ? String(defaultScenario.id) : null
|
||||
if (!scenarioId) {
|
||||
const liveRes = await pool.query(`SELECT id FROM fp_scenarios WHERE is_default = true LIMIT 1`)
|
||||
scenarioId = liveRes.rows[0]?.id ? String(liveRes.rows[0].id) : null
|
||||
}
|
||||
if (scenarioId) {
|
||||
try {
|
||||
const fpResponse = await finanzplanToFMResults(pool, scenarioId)
|
||||
fmResults = fpResponse.results
|
||||
} catch {
|
||||
fmResults = []
|
||||
}
|
||||
}
|
||||
|
||||
const rawAssumptions = (map.fm_assumptions || []) as Array<Record<string, unknown>>
|
||||
fmAssumptions = rawAssumptions.map(a => ({
|
||||
...a,
|
||||
value: typeof a.value === 'string' ? JSON.parse(a.value as string) : a.value,
|
||||
})) as FMAssumption[]
|
||||
|
||||
return (
|
||||
<PrintDeck
|
||||
|
||||
@@ -1,9 +1,30 @@
|
||||
/* Fonts: Inter (body/headings) + JetBrains Mono (kickers, tickers, page numbers).
|
||||
Plus Jakarta Sans is still loaded by globals.css; we don't need it for print. */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500;600;700&display=swap');
|
||||
|
||||
/* Named page — must be outside @media print */
|
||||
@page slide-page {
|
||||
size: A4 landscape;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
* Page background: violet-tinted radial gradient with a faint dotted-grid
|
||||
* overlay (printed via two layered background-images on .print-page).
|
||||
*
|
||||
* The radial mimics Claude Design's `radial-gradient(ellipse at 50% 12%, #fff 0%, #f5efff 55%, #ebdfff 100%)`.
|
||||
* The dots are a tiny SVG repeat tile at 24px pitch, ~6% slate, so the grid is
|
||||
* just barely visible — same role as the dot-grid in the design reference.
|
||||
*/
|
||||
.print-page-bg {
|
||||
background-color: #ffffff;
|
||||
background-image:
|
||||
url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24'%3E%3Ccircle cx='1' cy='1' r='0.6' fill='%23a78bfa' fill-opacity='0.18'/%3E%3C/svg%3E"),
|
||||
radial-gradient(ellipse at 50% 8%, #ffffff 0%, #f5efff 55%, #ebdfff 100%);
|
||||
background-repeat: repeat, no-repeat;
|
||||
background-size: 24px 24px, cover;
|
||||
}
|
||||
|
||||
@media screen {
|
||||
body { background: #d1d5db; }
|
||||
}
|
||||
@@ -14,22 +35,19 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
* globals.css sets html,body { height:100%; overflow:hidden; background:#0a0a1a }.
|
||||
* In print mode that clips all content to one viewport height and renders a black
|
||||
* background. Override everything here.
|
||||
*/
|
||||
html, body {
|
||||
height: auto !important;
|
||||
min-height: 0 !important;
|
||||
overflow: visible !important;
|
||||
background: #ffffff !important;
|
||||
color: #000000 !important;
|
||||
color: #1a0f34 !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
font-family: 'Inter', 'Plus Jakarta Sans', system-ui, -apple-system, sans-serif !important;
|
||||
-webkit-print-color-adjust: exact;
|
||||
-moz-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.no-print {
|
||||
@@ -41,12 +59,10 @@
|
||||
margin: 0 !important;
|
||||
display: block !important;
|
||||
overflow: visible !important;
|
||||
font-family: 'Inter', system-ui, sans-serif !important;
|
||||
}
|
||||
|
||||
/*
|
||||
* Block wrapper: carries the height AND the page break.
|
||||
* height:210mm on display:block is reliable in both Chrome and Firefox.
|
||||
*/
|
||||
/* Block wrapper carries the height + page break. */
|
||||
.print-page-break {
|
||||
page: slide-page;
|
||||
display: block !important;
|
||||
@@ -72,9 +88,29 @@
|
||||
overflow: hidden !important;
|
||||
margin: 0 !important;
|
||||
box-shadow: none !important;
|
||||
background: #ffffff !important;
|
||||
font-family: 'Inter', system-ui, sans-serif !important;
|
||||
color: #1a0f34 !important;
|
||||
-webkit-print-color-adjust: exact;
|
||||
-moz-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
|
||||
/* Tabular numerals everywhere */
|
||||
.print-page table, .print-page .num, .print-page .kpi {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Mono utility — for kickers, page numbers, tickers, code-style tags */
|
||||
.print-mono {
|
||||
font-family: 'JetBrains Mono', ui-monospace, Menlo, Consolas, monospace !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Screen preview: same fonts, applied to print-page on screen too */
|
||||
.print-page, .print-page-break {
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.print-mono {
|
||||
font-family: 'JetBrains Mono', ui-monospace, Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -17,18 +17,20 @@ export async function finanzplanToFMResults(pool: Pool, scenarioId?: string): Pr
|
||||
}
|
||||
|
||||
// Load computed data
|
||||
const [personalRes, liquidRes, betriebRes, umsatzRes, materialRes, investRes] = await Promise.all([
|
||||
const [personalRes, liquidRes, betriebRes, umsatzRes, materialRes, investRes, kundenRes] = await Promise.all([
|
||||
pool.query("SELECT * FROM fp_personalkosten WHERE scenario_id = $1 ORDER BY sort_order", [sid]),
|
||||
pool.query("SELECT * FROM fp_liquiditaet WHERE scenario_id = $1 ORDER BY sort_order", [sid]),
|
||||
pool.query("SELECT * FROM fp_betriebliche_aufwendungen WHERE scenario_id = $1 ORDER BY sort_order", [sid]),
|
||||
pool.query("SELECT * FROM fp_umsatzerloese WHERE scenario_id = $1 AND section = 'revenue' AND row_label = 'GESAMTUMSATZ' LIMIT 1", [sid]),
|
||||
pool.query("SELECT * FROM fp_materialaufwand WHERE scenario_id = $1 AND row_label = 'SUMME' LIMIT 1", [sid]),
|
||||
pool.query("SELECT * FROM fp_investitionen WHERE scenario_id = $1 ORDER BY sort_order", [sid]),
|
||||
pool.query("SELECT * FROM fp_kunden_summary WHERE scenario_id = $1 AND row_label = 'Bestandskunden gesamt' LIMIT 1", [sid]),
|
||||
])
|
||||
|
||||
const personal = personalRes.rows
|
||||
const liquid = liquidRes.rows
|
||||
const betrieb = betriebRes.rows
|
||||
const customersByMonth = (kundenRes.rows[0]?.values as MonthlyValues) || emptyMonthly()
|
||||
|
||||
// Helper to sum a field across personnel
|
||||
function sumPersonalField(field: string): MonthlyValues {
|
||||
@@ -92,9 +94,9 @@ export async function finanzplanToFMResults(pool: Pool, scenarioId?: string): Pr
|
||||
month: m,
|
||||
year,
|
||||
month_in_year: month,
|
||||
new_customers: 0,
|
||||
new_customers: Math.max((customersByMonth[`m${m}`] || 0) - prevCustomers, 0),
|
||||
churned_customers: 0,
|
||||
total_customers: 0,
|
||||
total_customers: Math.round(customersByMonth[`m${m}`] || 0),
|
||||
mrr_eur: Math.round(rev / 1), // monthly
|
||||
arr_eur: Math.round(rev * 12),
|
||||
revenue_eur: Math.round(rev),
|
||||
@@ -113,6 +115,7 @@ export async function finanzplanToFMResults(pool: Pool, scenarioId?: string): Pr
|
||||
cash_balance_eur: Math.round(cash),
|
||||
cumulative_revenue_eur: Math.round(cumulativeRevenue),
|
||||
})
|
||||
prevCustomers = customersByMonth[`m${m}`] || 0
|
||||
}
|
||||
|
||||
// Summary
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Add inflation formulas to 'Betriebliche Aufwendungen' rows + update Büromiete.
|
||||
|
||||
What this does:
|
||||
1. Update row 27 (Büromiete): 0 in Aug 2026, then 1000 €/Monat from Sep 2026 to
|
||||
Dec 2026 (hardcoded), then inflation-adjusted formulas from Jan 2027 onwards.
|
||||
2. Add Treiber driver block 'Inflation Betriebskosten' (one rate per year).
|
||||
3. For each eligible row, keep 2026 values hardcoded (the 'Startwert') and
|
||||
replace cells from Jan 2027 onwards with a formula that:
|
||||
- carries the previous month's value forward in non-January months
|
||||
- multiplies by (1 + inflation_for_this_year) every January
|
||||
|
||||
Eligibility (which rows get inflation formulas):
|
||||
- Row contains numeric values (not formulas) — formula rows like KFZ that
|
||||
scale via Personalkosten are left alone.
|
||||
- Row has at least 2 non-zero months in 2026 (constant or 'later activation'
|
||||
patterns: D&O insurance, Recruiting, Buchführung, etc.).
|
||||
- Yearly-fee rows (exactly 1 non-zero month in 2026 — e.g., IHK in Sep)
|
||||
get a year-over-year carry: same month next year = last year × (1+inflation),
|
||||
other months stay 0.
|
||||
- Rows where all 2026 values are 0 are skipped.
|
||||
- Subtotal/summe rows are formulas already → automatically skipped.
|
||||
|
||||
Usage:
|
||||
python3 pitch-deck/scripts/add-inflation-formulas.py --dry-run
|
||||
python3 pitch-deck/scripts/add-inflation-formulas.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import shutil
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from openpyxl import load_workbook
|
||||
from openpyxl.utils import get_column_letter
|
||||
|
||||
EXPORTS = Path(__file__).resolve().parent.parent / "exports"
|
||||
|
||||
BUEROMIETE_ROW = 27
|
||||
BUEROMIETE_START_MONTH = 9 # September
|
||||
BUEROMIETE_VALUE = 1000
|
||||
|
||||
DEFAULT_INFLATION = 0.03
|
||||
|
||||
_BA_SHEET = "Betriebliche Aufwendungen"
|
||||
|
||||
|
||||
def year_columns(ws) -> dict[int, list[int]]:
|
||||
out: dict[int, list[int]] = {}
|
||||
for c in range(2, ws.max_column + 1):
|
||||
v = ws.cell(row=1, column=c).value
|
||||
if v is None:
|
||||
continue
|
||||
try:
|
||||
yr = int(v)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
out.setdefault(yr, []).append(c)
|
||||
return out
|
||||
|
||||
|
||||
def cols_by_year_month(ws) -> dict[tuple[int, int], int]:
|
||||
"""Return {(year, month): column_index} based on rows 1 and 2."""
|
||||
out: dict[tuple[int, int], int] = {}
|
||||
for c in range(2, ws.max_column + 1):
|
||||
y = ws.cell(row=1, column=c).value
|
||||
m = ws.cell(row=2, column=c).value
|
||||
try:
|
||||
out[(int(y), int(m))] = c
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Treiber: add or read inflation driver block
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
INFLATION_HEADER = "Inflation Betriebskosten"
|
||||
|
||||
|
||||
def find_or_create_inflation_block(ws_t, years_after_start: list[int]) -> dict[int, int]:
|
||||
"""Find existing inflation block by header, or append a new one. Return {year: row}."""
|
||||
# Look for existing header
|
||||
header_row = None
|
||||
for r in range(1, ws_t.max_row + 1):
|
||||
if ws_t.cell(row=r, column=1).value == INFLATION_HEADER:
|
||||
header_row = r
|
||||
break
|
||||
|
||||
if header_row is None:
|
||||
# Append new block
|
||||
header_row = ws_t.max_row + 2 # blank then header
|
||||
ws_t.cell(row=header_row, column=1).value = INFLATION_HEADER
|
||||
|
||||
refs: dict[int, int] = {}
|
||||
r = header_row + 1
|
||||
for yr in years_after_start:
|
||||
label_cell = ws_t.cell(row=r, column=1)
|
||||
if label_cell.value is None or not str(label_cell.value).startswith("Inflation"):
|
||||
# Fresh entry
|
||||
ws_t.cell(row=r, column=1).value = f"Inflation {yr}"
|
||||
ws_t.cell(row=r, column=2).value = DEFAULT_INFLATION
|
||||
# If already there, preserve existing value
|
||||
refs[yr] = r
|
||||
r += 1
|
||||
return refs
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Büromiete special handling (row 27)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def update_bueromiete(ws_ba, col_index: dict, base_year: int, inflation_refs: dict[int, int]) -> int:
|
||||
"""Set row 27: 0 in Aug 2026, 1000 from Sep 2026 to Dec 2026 (hardcoded),
|
||||
then inflation formula from Jan 2027 onwards.
|
||||
"""
|
||||
inc_first = min(inflation_refs.values())
|
||||
inc_last = max(inflation_refs.values())
|
||||
|
||||
# Keep label free of the word 'Inflation' — number-formatting heuristics
|
||||
# treat that as a percent indicator and would mis-format Euro cells.
|
||||
ws_ba.cell(row=BUEROMIETE_ROW, column=1).value = "Büromiete (ab Sep 2026)"
|
||||
|
||||
written = 0
|
||||
for c in range(2, ws_ba.max_column + 1):
|
||||
y = ws_ba.cell(row=1, column=c).value
|
||||
m = ws_ba.cell(row=2, column=c).value
|
||||
if y == base_year:
|
||||
# Hardcoded 2026 values
|
||||
val = BUEROMIETE_VALUE if m >= BUEROMIETE_START_MONTH else 0
|
||||
ws_ba.cell(row=BUEROMIETE_ROW, column=c).value = val
|
||||
written += 1
|
||||
else:
|
||||
# Inflation formula from Jan 2027 onwards
|
||||
col_letter = get_column_letter(c)
|
||||
prev_col = get_column_letter(c - 1)
|
||||
ws_ba.cell(row=BUEROMIETE_ROW, column=c).value = (
|
||||
f"=IF(AND({col_letter}$2=1,{col_letter}$1>{base_year}),"
|
||||
f"{prev_col}{BUEROMIETE_ROW}*(1+INDEX(Treiber!$B${inc_first}:$B${inc_last},{col_letter}$1-{base_year})),"
|
||||
f"{prev_col}{BUEROMIETE_ROW})"
|
||||
)
|
||||
written += 1
|
||||
return written
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# General inflation application to qualifying rows
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def is_formula(value) -> bool:
|
||||
return isinstance(value, str) and value.startswith("=")
|
||||
|
||||
|
||||
def collect_eligible_rows(ws_ba, base_year_cols: list[int], skip_rows: set[int]) -> dict[int, str]:
|
||||
"""For each row, decide if it's eligible and classify it.
|
||||
|
||||
Returns {row: kind} where kind is one of:
|
||||
'monthly' - ≥2 non-zero months in 2026, apply Jan-inflation carry-forward
|
||||
'yearly' - exactly 1 non-zero month in 2026, apply same-month-next-year
|
||||
(rows not eligible are absent from the dict)
|
||||
"""
|
||||
out: dict[int, str] = {}
|
||||
for r in range(4, ws_ba.max_row + 1):
|
||||
if r in skip_rows:
|
||||
continue
|
||||
label = ws_ba.cell(row=r, column=1).value
|
||||
if not label:
|
||||
continue
|
||||
if str(label).startswith(("summe —", "Summe ")) or "SUMME" in str(label):
|
||||
continue
|
||||
|
||||
values_2026 = [ws_ba.cell(row=r, column=c).value for c in base_year_cols]
|
||||
if any(is_formula(v) for v in values_2026):
|
||||
continue
|
||||
nonzero_count = sum(1 for v in values_2026 if v not in (None, 0, ""))
|
||||
if nonzero_count == 0:
|
||||
continue
|
||||
if nonzero_count == 1:
|
||||
out[r] = "yearly"
|
||||
else:
|
||||
out[r] = "monthly"
|
||||
return out
|
||||
|
||||
|
||||
def write_monthly_inflation(ws_ba, row: int, first_formula_col: int, base_year: int,
|
||||
inflation_refs: dict[int, int]) -> int:
|
||||
"""From first_formula_col onwards, carry previous + Jan inflation."""
|
||||
inc_first = min(inflation_refs.values())
|
||||
inc_last = max(inflation_refs.values())
|
||||
written = 0
|
||||
for c in range(first_formula_col, ws_ba.max_column + 1):
|
||||
col_letter = get_column_letter(c)
|
||||
prev_col = get_column_letter(c - 1)
|
||||
ws_ba.cell(row=row, column=c).value = (
|
||||
f"=IF(AND({col_letter}$2=1,{col_letter}$1>{base_year}),"
|
||||
f"{prev_col}{row}*(1+INDEX(Treiber!$B${inc_first}:$B${inc_last},{col_letter}$1-{base_year})),"
|
||||
f"{prev_col}{row})"
|
||||
)
|
||||
written += 1
|
||||
return written
|
||||
|
||||
|
||||
def write_yearly_inflation(ws_ba, row: int, first_formula_col: int, base_year: int,
|
||||
inflation_refs: dict[int, int], col_index: dict) -> int:
|
||||
"""For yearly-fee rows: same month next year × (1 + inflation), else 0."""
|
||||
inc_first = min(inflation_refs.values())
|
||||
inc_last = max(inflation_refs.values())
|
||||
written = 0
|
||||
for c in range(first_formula_col, ws_ba.max_column + 1):
|
||||
y = ws_ba.cell(row=1, column=c).value
|
||||
m = ws_ba.cell(row=2, column=c).value
|
||||
if y is None or m is None:
|
||||
continue
|
||||
same_month_prev_year = col_index.get((int(y) - 1, int(m)))
|
||||
if same_month_prev_year is None:
|
||||
continue
|
||||
prev_letter = get_column_letter(same_month_prev_year)
|
||||
col_letter = get_column_letter(c)
|
||||
ws_ba.cell(row=row, column=c).value = (
|
||||
f"={prev_letter}{row}*(1+INDEX(Treiber!$B${inc_first}:$B${inc_last},{col_letter}$1-{base_year}))"
|
||||
)
|
||||
written += 1
|
||||
return written
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main per-file processing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def process_file(path: Path, dry_run: bool) -> dict | None:
|
||||
wb = load_workbook(path)
|
||||
if _BA_SHEET not in wb.sheetnames or "Treiber" not in wb.sheetnames:
|
||||
return None
|
||||
ws_ba = wb[_BA_SHEET]
|
||||
ws_t = wb["Treiber"]
|
||||
|
||||
yc = year_columns(ws_ba)
|
||||
years = sorted(yc.keys())
|
||||
base_year = min(years)
|
||||
years_after_start = [y for y in years if y > base_year]
|
||||
|
||||
inflation_refs = find_or_create_inflation_block(ws_t, years_after_start)
|
||||
|
||||
col_index = cols_by_year_month(ws_ba)
|
||||
base_year_cols = yc[base_year]
|
||||
first_year_after = min(years_after_start)
|
||||
first_formula_col = col_index.get((first_year_after, 1))
|
||||
if first_formula_col is None:
|
||||
return None
|
||||
|
||||
# Büromiete (always)
|
||||
bueromiete_cells = update_bueromiete(ws_ba, col_index, base_year, inflation_refs)
|
||||
|
||||
# Determine eligible rows (skip Büromiete since we just handled it)
|
||||
skip = {BUEROMIETE_ROW}
|
||||
eligible = collect_eligible_rows(ws_ba, base_year_cols, skip)
|
||||
|
||||
cells_monthly = 0
|
||||
cells_yearly = 0
|
||||
n_monthly = n_yearly = 0
|
||||
for row, kind in eligible.items():
|
||||
if kind == "monthly":
|
||||
cells_monthly += write_monthly_inflation(ws_ba, row, first_formula_col, base_year, inflation_refs)
|
||||
n_monthly += 1
|
||||
else:
|
||||
cells_yearly += write_yearly_inflation(ws_ba, row, first_formula_col, base_year, inflation_refs, col_index)
|
||||
n_yearly += 1
|
||||
|
||||
if not dry_run:
|
||||
wb.save(path)
|
||||
|
||||
return {
|
||||
"years": years,
|
||||
"monthly_rows": n_monthly,
|
||||
"yearly_rows": n_yearly,
|
||||
"cells_bueromiete": bueromiete_cells,
|
||||
"cells_monthly": cells_monthly,
|
||||
"cells_yearly": cells_yearly,
|
||||
"eligible_rows": {kind: [r for r, k in eligible.items() if k == kind] for kind in ("monthly", "yearly")},
|
||||
}
|
||||
|
||||
|
||||
def backup(path: Path) -> Path:
|
||||
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
bk = path.with_name(f"{path.stem}.BACKUP-pre-inflation-{ts}{path.suffix}")
|
||||
shutil.copy2(path, bk)
|
||||
return bk
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("--dry-run", action="store_true")
|
||||
ap.add_argument("--only", help="Process only this filename")
|
||||
ap.add_argument("--no-backup", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
files = sorted(EXPORTS.glob("Finanzplan-*.xlsx"))
|
||||
files = [f for f in files if "BACKUP" not in f.name]
|
||||
if args.only:
|
||||
files = [f for f in files if f.name == args.only]
|
||||
|
||||
for path in files:
|
||||
wb_peek = load_workbook(path, read_only=True)
|
||||
if _BA_SHEET not in wb_peek.sheetnames or "Treiber" not in wb_peek.sheetnames:
|
||||
print(f"\n ⨯ skip {path.name}: missing sheets")
|
||||
continue
|
||||
if not args.dry_run and not args.no_backup:
|
||||
bk = backup(path)
|
||||
print(f" ✓ backup: {bk.name}")
|
||||
info = process_file(path, dry_run=args.dry_run)
|
||||
if info is None:
|
||||
continue
|
||||
print(f"\n === {path.name} ===")
|
||||
print(f" Büromiete (row {BUEROMIETE_ROW}): {info['cells_bueromiete']} cells (Sep 2026 start)")
|
||||
print(f" Monthly-inflation rows: {info['monthly_rows']} ({info['cells_monthly']} cells)")
|
||||
print(f" Yearly-fee rows: {info['yearly_rows']} ({info['cells_yearly']} cells)")
|
||||
print(f" Eligible monthly rows: {info['eligible_rows']['monthly']}")
|
||||
print(f" Eligible yearly rows: {info['eligible_rows']['yearly']}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,323 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Replace hard-coded values in 'Kunden' sheet with proper formulas:
|
||||
|
||||
- Neukunden = driver lookup (per year × segment) from Treiber sheet
|
||||
- Churn = ROUND(previous-month Bestandskunden × Churn-Rate, 0)
|
||||
- Bestandskunden = previous + new - churn (cumulative)
|
||||
|
||||
Default driver values are derived from each file's existing monthly Neukunden
|
||||
data so each scenario keeps its growth profile. Churn rates default to
|
||||
industry-typical B2B SaaS values (Starter 1%, Pro 0.5%, Enterprise 0.3%/Monat).
|
||||
|
||||
Usage:
|
||||
python3 pitch-deck/scripts/add-kunden-formulas.py --dry-run
|
||||
python3 pitch-deck/scripts/add-kunden-formulas.py
|
||||
python3 pitch-deck/scripts/add-kunden-formulas.py --only Finanzplan-Wandeldarlehen-400k.xlsx
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import shutil
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from openpyxl import load_workbook
|
||||
from openpyxl.utils import get_column_letter
|
||||
|
||||
EXPORTS = Path(__file__).resolve().parent.parent / "exports"
|
||||
|
||||
# Default monthly churn rates per segment per year. Reflects business reality:
|
||||
# - Starter: high early churn (testing, startups failing) decreasing as product matures
|
||||
# - Professional: moderate, gradually decreasing
|
||||
# - Enterprise: very low — they integrate the product, don't switch
|
||||
# Annual equivalents:
|
||||
# Starter 64% → 17%, Professional 26% → 9%, Enterprise 6% → 1%
|
||||
CHURN_DEFAULTS: dict[str, dict[int, float]] = {
|
||||
"starter": {2026: 0.08, 2027: 0.05, 2028: 0.03, 2029: 0.02, 2030: 0.015},
|
||||
"professional": {2026: 0.025, 2027: 0.02, 2028: 0.015, 2029: 0.01, 2030: 0.008},
|
||||
"enterprise": {2026: 0.005, 2027: 0.003, 2028: 0.002, 2029: 0.001, 2030: 0.001},
|
||||
}
|
||||
|
||||
# Segment definitions: (segment_name, neukunden_row, churn_row, bestand_row, suffix)
|
||||
SEGMENTS = [
|
||||
("Starter (<10 MA)", 4, 5, 6, "starter"),
|
||||
("Professional (10-250 MA)", 7, 8, 9, "professional"),
|
||||
("Enterprise (250+ MA)", 10, 11, 12, "enterprise"),
|
||||
]
|
||||
|
||||
|
||||
def year_columns(ws_kunden) -> dict[int, list[int]]:
|
||||
out: dict[int, list[int]] = {}
|
||||
for c in range(2, ws_kunden.max_column + 1):
|
||||
v = ws_kunden.cell(row=1, column=c).value
|
||||
if v is None:
|
||||
continue
|
||||
try:
|
||||
yr = int(v)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
out.setdefault(yr, []).append(c)
|
||||
return out
|
||||
|
||||
|
||||
def monthly_avg_per_year(ws_kunden, row: int, year_cols: dict[int, list[int]]) -> dict[int, int]:
|
||||
"""Compute rounded monthly average from existing per-month values.
|
||||
|
||||
Uses half-up rounding (0.5 → 1) instead of Python's banker's rounding (0.5 → 0)
|
||||
to preserve segments that grew at exactly half-a-customer-per-month.
|
||||
"""
|
||||
res: dict[int, int] = {}
|
||||
for yr, cols in year_cols.items():
|
||||
total = 0.0
|
||||
for c in cols:
|
||||
v = ws_kunden.cell(row=row, column=c).value
|
||||
if v in (None, ""):
|
||||
continue
|
||||
try:
|
||||
total += float(v)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
avg = total / len(cols) if cols else 0
|
||||
res[yr] = max(0, int(avg + 0.5))
|
||||
return res
|
||||
|
||||
|
||||
_SEG_LABEL = {"starter": "Starter", "professional": "Professional", "enterprise": "Enterprise"}
|
||||
|
||||
|
||||
def _clear_old_kundenakquise_block(ws_treiber) -> None:
|
||||
"""If we ran a previous version, drop any rows after the original 30 to start fresh."""
|
||||
if ws_treiber.max_row > 30:
|
||||
for r in range(31, ws_treiber.max_row + 1):
|
||||
ws_treiber.cell(row=r, column=1).value = None
|
||||
ws_treiber.cell(row=r, column=2).value = None
|
||||
|
||||
|
||||
def write_treiber_drivers(ws_treiber, years: list[int], defaults_by_segment: dict[str, dict[int, int]]) -> dict:
|
||||
"""Rewrite the Kundenakquise + Churn driver block. Returns row indices for cross-references.
|
||||
|
||||
Layout (after original row 30):
|
||||
31 (blank)
|
||||
32 Header "Kundenakquise"
|
||||
33..47 Neukunden/Monat per (segment, year)
|
||||
48 (blank)
|
||||
49 Header "Churn (monatliche Rate, pro Jahr)"
|
||||
50..64 Churn-Rate/Monat per (segment, year)
|
||||
"""
|
||||
_clear_old_kundenakquise_block(ws_treiber)
|
||||
|
||||
r = 32
|
||||
ws_treiber.cell(row=r, column=1).value = "Kundenakquise"
|
||||
r += 1
|
||||
new_customer_refs: dict[str, dict[int, int]] = {}
|
||||
for suffix in ("starter", "professional", "enterprise"):
|
||||
new_customer_refs[suffix] = {}
|
||||
for yr in years:
|
||||
ws_treiber.cell(row=r, column=1).value = f"Neukunden/Monat {_SEG_LABEL[suffix]} {yr}"
|
||||
ws_treiber.cell(row=r, column=2).value = defaults_by_segment[suffix][yr]
|
||||
new_customer_refs[suffix][yr] = r
|
||||
r += 1
|
||||
r += 1 # blank row 48
|
||||
ws_treiber.cell(row=r, column=1).value = "Churn (monatliche Rate, pro Jahr)"
|
||||
r += 1
|
||||
churn_refs: dict[str, dict[int, int]] = {}
|
||||
for suffix in ("starter", "professional", "enterprise"):
|
||||
churn_refs[suffix] = {}
|
||||
for yr in years:
|
||||
ws_treiber.cell(row=r, column=1).value = f"Churn-Rate/Monat {_SEG_LABEL[suffix]} {yr}"
|
||||
ws_treiber.cell(row=r, column=2).value = CHURN_DEFAULTS[suffix][yr]
|
||||
churn_refs[suffix][yr] = r
|
||||
r += 1
|
||||
return {"new_customer_rows": new_customer_refs, "churn_rows": churn_refs}
|
||||
|
||||
|
||||
# Helper rows in Kunden sheet (added BELOW the totals to avoid breaking external refs).
|
||||
# Each helper row holds the year-varying monthly churn rate per column for one segment.
|
||||
HELPER_ROWS = {
|
||||
"starter": 18,
|
||||
"professional": 19,
|
||||
"enterprise": 20,
|
||||
}
|
||||
|
||||
|
||||
def write_kunden_formulas(ws_kunden, years: list[int], refs: dict) -> dict:
|
||||
min_year = min(years)
|
||||
new_refs = refs["new_customer_rows"]
|
||||
churn_refs = refs["churn_rows"]
|
||||
stats = {"neu": 0, "churn": 0, "bestand": 0, "helper": 0}
|
||||
|
||||
# 1. Write per-segment helper rows (18-20) with year-lookup rates per column.
|
||||
ws_kunden.cell(row=17, column=1).value = None # separator
|
||||
for suffix, helper_row in HELPER_ROWS.items():
|
||||
first = min(churn_refs[suffix].values())
|
||||
last = max(churn_refs[suffix].values())
|
||||
ws_kunden.cell(row=helper_row, column=1).value = (
|
||||
f"Monatl. Churn-Rate {_SEG_LABEL[suffix]} (Helper)"
|
||||
)
|
||||
for c in range(2, ws_kunden.max_column + 1):
|
||||
col_letter = get_column_letter(c)
|
||||
ws_kunden.cell(row=helper_row, column=c).value = (
|
||||
f"=INDEX(Treiber!$B${first}:$B${last},{col_letter}$1-{min_year - 1})"
|
||||
)
|
||||
stats["helper"] += 1
|
||||
|
||||
# 2. Write per-segment Neukunden/Churn/Bestandskunden formulas.
|
||||
for seg_label, neu_row, churn_row, bestand_row, suffix in SEGMENTS:
|
||||
ws_kunden.cell(row=neu_row, column=1).value = f"Neukunden {seg_label}"
|
||||
ws_kunden.cell(row=churn_row, column=1).value = f"Churn {seg_label}"
|
||||
ws_kunden.cell(row=bestand_row, column=1).value = f"Bestandskunden {seg_label}"
|
||||
|
||||
seg_neu_rows = new_refs[suffix]
|
||||
neu_first = min(seg_neu_rows.values())
|
||||
neu_last = max(seg_neu_rows.values())
|
||||
helper_row = HELPER_ROWS[suffix]
|
||||
|
||||
for c in range(2, ws_kunden.max_column + 1):
|
||||
col_letter = get_column_letter(c)
|
||||
prev_col = get_column_letter(c - 1) if c > 2 else None
|
||||
|
||||
# --- Neukunden: year-lookup driver ---
|
||||
ws_kunden.cell(row=neu_row, column=c).value = (
|
||||
f"=INDEX(Treiber!$B${neu_first}:$B${neu_last},{col_letter}$1-{min_year - 1})"
|
||||
)
|
||||
stats["neu"] += 1
|
||||
|
||||
# --- Churn: cumulative-rounding to make small-base churn visible ---
|
||||
# Approach: expected_cum_churn(t) = SUMPRODUCT(Bestand[B..t-1], Rate[C..t])
|
||||
# Each month's churn = ROUND(expected_cum) - already_booked_churn.
|
||||
# This guarantees integer monthly values that aggregate to ROUND(expected_total).
|
||||
if c == 2:
|
||||
# Aug 2026: no prior month, no churn.
|
||||
ws_kunden.cell(row=churn_row, column=c).value = 0
|
||||
elif c == 3:
|
||||
# Sep 2026: one prior bestand cell (B), no churn-booked yet.
|
||||
ws_kunden.cell(row=churn_row, column=c).value = (
|
||||
f"=MAX(0,ROUND(SUMPRODUCT($B{bestand_row}:B{bestand_row},"
|
||||
f"$C{helper_row}:C{helper_row}),0))"
|
||||
)
|
||||
else:
|
||||
ws_kunden.cell(row=churn_row, column=c).value = (
|
||||
f"=MAX(0,ROUND(SUMPRODUCT($B{bestand_row}:{prev_col}{bestand_row},"
|
||||
f"$C{helper_row}:{col_letter}{helper_row}),0)"
|
||||
f"-SUM($C{churn_row}:{prev_col}{churn_row}))"
|
||||
)
|
||||
stats["churn"] += 1
|
||||
|
||||
# --- Bestandskunden: cumulative balance ---
|
||||
if c == 2:
|
||||
ws_kunden.cell(row=bestand_row, column=c).value = (
|
||||
f"={col_letter}{neu_row}-{col_letter}{churn_row}"
|
||||
)
|
||||
else:
|
||||
ws_kunden.cell(row=bestand_row, column=c).value = (
|
||||
f"={prev_col}{bestand_row}+{col_letter}{neu_row}-{col_letter}{churn_row}"
|
||||
)
|
||||
stats["bestand"] += 1
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
def _was_already_processed(ws_treiber) -> bool:
|
||||
"""Detect if a previous run already wrote the Kundenakquise driver block."""
|
||||
return ws_treiber.cell(row=32, column=1).value == "Kundenakquise"
|
||||
|
||||
|
||||
def _read_existing_neukunden_drivers(ws_treiber, years: list[int]) -> dict:
|
||||
"""Read driver values previously written to Treiber rows 33..47.
|
||||
|
||||
Re-running on a processed file would otherwise compute defaults from
|
||||
formula cells (which openpyxl returns as strings) and reset everything to 0.
|
||||
"""
|
||||
defaults: dict[str, dict[int, int]] = {}
|
||||
r = 33
|
||||
for suffix in ("starter", "professional", "enterprise"):
|
||||
defaults[suffix] = {}
|
||||
for yr in years:
|
||||
v = ws_treiber.cell(row=r, column=2).value
|
||||
try:
|
||||
defaults[suffix][yr] = int(round(float(v))) if v is not None else 0
|
||||
except (TypeError, ValueError):
|
||||
defaults[suffix][yr] = 0
|
||||
r += 1
|
||||
return defaults
|
||||
|
||||
|
||||
def process_file(path: Path, dry_run: bool) -> dict | None:
|
||||
wb = load_workbook(path)
|
||||
if "Kunden" not in wb.sheetnames or "Treiber" not in wb.sheetnames:
|
||||
return None # caller will report skip
|
||||
|
||||
ws_k = wb["Kunden"]
|
||||
ws_t = wb["Treiber"]
|
||||
|
||||
yc = year_columns(ws_k)
|
||||
years = sorted(yc.keys())
|
||||
|
||||
if _was_already_processed(ws_t):
|
||||
# Preserve user-edited driver values from a previous run
|
||||
defaults = _read_existing_neukunden_drivers(ws_t, years)
|
||||
source = "treiber (preserved)"
|
||||
else:
|
||||
# First time: compute defaults from existing Kunden values
|
||||
defaults = {
|
||||
"starter": monthly_avg_per_year(ws_k, 4, yc),
|
||||
"professional": monthly_avg_per_year(ws_k, 7, yc),
|
||||
"enterprise": monthly_avg_per_year(ws_k, 10, yc),
|
||||
}
|
||||
source = "kunden data"
|
||||
|
||||
refs = write_treiber_drivers(ws_t, years, defaults)
|
||||
stats = write_kunden_formulas(ws_k, years, refs)
|
||||
|
||||
if not dry_run:
|
||||
wb.save(path)
|
||||
|
||||
return {"years": years, "defaults": defaults, "stats": stats, "refs": refs, "defaults_source": source}
|
||||
|
||||
|
||||
def backup(path: Path) -> Path:
|
||||
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
bk = path.with_name(f"{path.stem}.BACKUP-pre-kunden-formulas-{ts}{path.suffix}")
|
||||
shutil.copy2(path, bk)
|
||||
return bk
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("--dry-run", action="store_true")
|
||||
ap.add_argument("--only", help="Process only this filename")
|
||||
ap.add_argument("--no-backup", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
files = sorted(EXPORTS.glob("Finanzplan-*.xlsx"))
|
||||
files = [f for f in files if "BACKUP" not in f.name]
|
||||
if args.only:
|
||||
files = [f for f in files if f.name == args.only]
|
||||
|
||||
for path in files:
|
||||
# Peek first to decide whether to backup
|
||||
wb_peek = load_workbook(path, read_only=True)
|
||||
if "Treiber" not in wb_peek.sheetnames or "Kunden" not in wb_peek.sheetnames:
|
||||
print(f"\n ⨯ skip {path.name}: no Treiber sheet")
|
||||
continue
|
||||
if not args.dry_run and not args.no_backup:
|
||||
bk = backup(path)
|
||||
print(f" ✓ backup: {bk.name}")
|
||||
info = process_file(path, dry_run=args.dry_run)
|
||||
if info is None:
|
||||
print(f" ⨯ skip {path.name}: structural mismatch")
|
||||
continue
|
||||
print(f"\n === {path.name} ===")
|
||||
print(f" Years: {info['years']}")
|
||||
print(f" Defaults source: {info['defaults_source']}")
|
||||
print(" Neukunden/Monat:")
|
||||
for seg in ("starter", "professional", "enterprise"):
|
||||
print(f" {seg:13s}: {info['defaults'][seg]}")
|
||||
print(f" Cells written: {info['stats']}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,275 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Add price-escalation formulas to Finanzplan Excel files.
|
||||
|
||||
Replaces hard-coded Preis/Monat values in 'Umsatzerlöse' rows 4, 7, 10 with:
|
||||
- Starting price (Aug 2026) read from a Treiber driver
|
||||
- Annual price increase applied in January of each subsequent year
|
||||
- Increase percentage configurable per year via Treiber driver
|
||||
|
||||
Treiber layout (appended after existing rows):
|
||||
blank
|
||||
'Preise'
|
||||
'Startpreis/Monat Starter' → 300
|
||||
'Startpreis/Monat Professional' → 2083
|
||||
'Startpreis/Monat Enterprise' → 4167
|
||||
blank
|
||||
'Preiserhöhung pro Jahr (%)'
|
||||
'Preiserhöhung 2027' → 0.03
|
||||
'Preiserhöhung 2028' → 0.03
|
||||
'Preiserhöhung 2029' → 0.03
|
||||
'Preiserhöhung 2030' → 0.03
|
||||
|
||||
Usage:
|
||||
python3 pitch-deck/scripts/add-price-formulas.py --dry-run
|
||||
python3 pitch-deck/scripts/add-price-formulas.py
|
||||
python3 pitch-deck/scripts/add-price-formulas.py --only Finanzplan-Wandeldarlehen-400k.xlsx
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import shutil
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from openpyxl import load_workbook
|
||||
from openpyxl.utils import get_column_letter
|
||||
|
||||
EXPORTS = Path(__file__).resolve().parent.parent / "exports"
|
||||
|
||||
# Segment → row in Umsatzerlöse where Preis/Monat lives
|
||||
PRICE_ROWS = {
|
||||
"starter": 4,
|
||||
"professional": 7,
|
||||
"enterprise": 10,
|
||||
}
|
||||
|
||||
# Default starting prices (Aug 2026) — used only if existing value isn't a number
|
||||
DEFAULT_START_PRICES = {
|
||||
"starter": 300,
|
||||
"professional": 2083,
|
||||
"enterprise": 4167,
|
||||
}
|
||||
|
||||
DEFAULT_PRICE_INCREASE = 0.03 # 3% per year by default
|
||||
|
||||
_SEG_LABEL = {"starter": "Starter", "professional": "Professional", "enterprise": "Enterprise"}
|
||||
|
||||
|
||||
def year_columns(ws_kunden) -> dict[int, list[int]]:
|
||||
out: dict[int, list[int]] = {}
|
||||
for c in range(2, ws_kunden.max_column + 1):
|
||||
v = ws_kunden.cell(row=1, column=c).value
|
||||
if v is None:
|
||||
continue
|
||||
try:
|
||||
yr = int(v)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
out.setdefault(yr, []).append(c)
|
||||
return out
|
||||
|
||||
|
||||
def existing_start_prices(ws_umsatz) -> dict[str, float]:
|
||||
"""Read current Aug 2026 (column B) prices to use as Startpreis defaults."""
|
||||
res: dict[str, float] = {}
|
||||
for suffix, row in PRICE_ROWS.items():
|
||||
v = ws_umsatz.cell(row=row, column=2).value
|
||||
try:
|
||||
res[suffix] = float(v) if v is not None else float(DEFAULT_START_PRICES[suffix])
|
||||
except (TypeError, ValueError):
|
||||
# Already a formula? Use default.
|
||||
res[suffix] = float(DEFAULT_START_PRICES[suffix])
|
||||
return res
|
||||
|
||||
|
||||
def already_processed(ws_treiber) -> tuple[bool, int]:
|
||||
"""Return (already_processed, start_row). start_row is where to place the Preise block."""
|
||||
# Scan for an existing 'Preise' header
|
||||
for r in range(1, ws_treiber.max_row + 1):
|
||||
if ws_treiber.cell(row=r, column=1).value == "Preise":
|
||||
return True, r
|
||||
# First time: place after max_row with a blank separator
|
||||
return False, ws_treiber.max_row + 2
|
||||
|
||||
|
||||
def read_existing_price_drivers(ws_treiber, header_row: int, years_after_start: list[int]) -> tuple[dict, dict, dict]:
|
||||
"""Read existing price drivers we wrote previously. Returns (start_prices, increases, refs)."""
|
||||
start_prices: dict[str, float] = {}
|
||||
increases: dict[int, float] = {}
|
||||
refs: dict[str, int | dict[int, int]] = {}
|
||||
|
||||
r = header_row + 1
|
||||
start_refs: dict[str, int] = {}
|
||||
for suffix in ("starter", "professional", "enterprise"):
|
||||
v = ws_treiber.cell(row=r, column=2).value
|
||||
try:
|
||||
start_prices[suffix] = float(v) if v is not None else float(DEFAULT_START_PRICES[suffix])
|
||||
except (TypeError, ValueError):
|
||||
start_prices[suffix] = float(DEFAULT_START_PRICES[suffix])
|
||||
start_refs[suffix] = r
|
||||
r += 1
|
||||
refs["start_refs"] = start_refs
|
||||
|
||||
r += 1 # blank
|
||||
r += 1 # 'Preiserhöhung' header
|
||||
increase_refs: dict[int, int] = {}
|
||||
for yr in years_after_start:
|
||||
v = ws_treiber.cell(row=r, column=2).value
|
||||
try:
|
||||
increases[yr] = float(v) if v is not None else DEFAULT_PRICE_INCREASE
|
||||
except (TypeError, ValueError):
|
||||
increases[yr] = DEFAULT_PRICE_INCREASE
|
||||
increase_refs[yr] = r
|
||||
r += 1
|
||||
refs["increase_refs"] = increase_refs
|
||||
|
||||
return start_prices, increases, refs
|
||||
|
||||
|
||||
def write_treiber_price_drivers(ws_treiber, start_row: int, years_after_start: list[int],
|
||||
start_prices: dict[str, float], increases: dict[int, float]) -> dict:
|
||||
r = start_row
|
||||
ws_treiber.cell(row=r, column=1).value = "Preise"
|
||||
r += 1
|
||||
start_refs: dict[str, int] = {}
|
||||
for suffix in ("starter", "professional", "enterprise"):
|
||||
ws_treiber.cell(row=r, column=1).value = f"Startpreis/Monat {_SEG_LABEL[suffix]}"
|
||||
ws_treiber.cell(row=r, column=2).value = start_prices[suffix]
|
||||
start_refs[suffix] = r
|
||||
r += 1
|
||||
r += 1 # blank
|
||||
ws_treiber.cell(row=r, column=1).value = "Preiserhöhung pro Jahr (%)"
|
||||
r += 1
|
||||
increase_refs: dict[int, int] = {}
|
||||
for yr in years_after_start:
|
||||
ws_treiber.cell(row=r, column=1).value = f"Preiserhöhung {yr}"
|
||||
ws_treiber.cell(row=r, column=2).value = increases.get(yr, DEFAULT_PRICE_INCREASE)
|
||||
increase_refs[yr] = r
|
||||
r += 1
|
||||
return {"start_refs": start_refs, "increase_refs": increase_refs}
|
||||
|
||||
|
||||
def write_umsatz_price_formulas(ws_umsatz, refs: dict, base_year: int) -> int:
|
||||
"""Write price formulas to Umsatzerlöse rows 4/7/10 across all month columns.
|
||||
|
||||
Formula structure:
|
||||
col B (Aug 2026, first month): =Treiber!$B$startpreis
|
||||
col C+ same year: =prev_col
|
||||
col where month=1 and year>base_year:
|
||||
=prev_col*(1+INDEX(Treiber!$B$inc_first:$B$inc_last,year-base_year))
|
||||
"""
|
||||
start_refs = refs["start_refs"]
|
||||
increase_refs = refs["increase_refs"]
|
||||
inc_first = min(increase_refs.values())
|
||||
inc_last = max(increase_refs.values())
|
||||
|
||||
cells_written = 0
|
||||
for suffix, row in PRICE_ROWS.items():
|
||||
# Clean label
|
||||
ws_umsatz.cell(row=row, column=1).value = f"Preis/Monat ({_SEG_LABEL[suffix]})"
|
||||
for c in range(2, ws_umsatz.max_column + 1):
|
||||
col_letter = get_column_letter(c)
|
||||
if c == 2:
|
||||
# Aug 2026 starting price
|
||||
ws_umsatz.cell(row=row, column=c).value = (
|
||||
f"=Treiber!$B${start_refs[suffix]}"
|
||||
)
|
||||
else:
|
||||
prev_col = get_column_letter(c - 1)
|
||||
ws_umsatz.cell(row=row, column=c).value = (
|
||||
f"=IF(AND({col_letter}$2=1,{col_letter}$1>{base_year}),"
|
||||
f"{prev_col}{row}*(1+INDEX(Treiber!$B${inc_first}:$B${inc_last},"
|
||||
f"{col_letter}$1-{base_year})),"
|
||||
f"{prev_col}{row})"
|
||||
)
|
||||
cells_written += 1
|
||||
return cells_written
|
||||
|
||||
|
||||
def process_file(path: Path, dry_run: bool) -> dict | None:
|
||||
wb = load_workbook(path)
|
||||
if "Umsatzerlöse" not in wb.sheetnames or "Treiber" not in wb.sheetnames or "Kunden" not in wb.sheetnames:
|
||||
return None
|
||||
|
||||
ws_u = wb["Umsatzerlöse"]
|
||||
ws_t = wb["Treiber"]
|
||||
ws_k = wb["Kunden"]
|
||||
|
||||
yc = year_columns(ws_k)
|
||||
years = sorted(yc.keys())
|
||||
base_year = min(years)
|
||||
years_after_start = [y for y in years if y > base_year]
|
||||
|
||||
processed, start_row = already_processed(ws_t)
|
||||
if processed:
|
||||
# Preserve existing driver values
|
||||
start_prices, increases, _ = read_existing_price_drivers(ws_t, start_row, years_after_start)
|
||||
source = "treiber (preserved)"
|
||||
else:
|
||||
# First time: read current Aug 2026 prices, default increases
|
||||
start_prices = existing_start_prices(ws_u)
|
||||
increases = {y: DEFAULT_PRICE_INCREASE for y in years_after_start}
|
||||
source = "umsatz column B"
|
||||
|
||||
refs = write_treiber_price_drivers(ws_t, start_row, years_after_start, start_prices, increases)
|
||||
cells = write_umsatz_price_formulas(ws_u, refs, base_year)
|
||||
|
||||
if not dry_run:
|
||||
wb.save(path)
|
||||
|
||||
return {
|
||||
"years": years,
|
||||
"start_prices": start_prices,
|
||||
"increases": increases,
|
||||
"cells": cells,
|
||||
"source": source,
|
||||
"refs": refs,
|
||||
}
|
||||
|
||||
|
||||
def backup(path: Path) -> Path:
|
||||
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
bk = path.with_name(f"{path.stem}.BACKUP-pre-price-formulas-{ts}{path.suffix}")
|
||||
shutil.copy2(path, bk)
|
||||
return bk
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("--dry-run", action="store_true")
|
||||
ap.add_argument("--only", help="Process only this filename")
|
||||
ap.add_argument("--no-backup", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
files = sorted(EXPORTS.glob("Finanzplan-*.xlsx"))
|
||||
files = [f for f in files if "BACKUP" not in f.name]
|
||||
if args.only:
|
||||
files = [f for f in files if f.name == args.only]
|
||||
|
||||
for path in files:
|
||||
wb_peek = load_workbook(path, read_only=True)
|
||||
if not all(s in wb_peek.sheetnames for s in ("Umsatzerlöse", "Treiber", "Kunden")):
|
||||
print(f"\n ⨯ skip {path.name}: missing required sheets")
|
||||
continue
|
||||
if not args.dry_run and not args.no_backup:
|
||||
bk = backup(path)
|
||||
print(f" ✓ backup: {bk.name}")
|
||||
info = process_file(path, dry_run=args.dry_run)
|
||||
if info is None:
|
||||
continue
|
||||
print(f"\n === {path.name} ===")
|
||||
print(f" Source: {info['source']}")
|
||||
print(f" Startpreise (Aug {min(info['years'])}):")
|
||||
for seg, val in info["start_prices"].items():
|
||||
print(f" {seg:13s}: {val:>8.2f} €/Monat")
|
||||
print(" Preiserhöhung pro Jahr (Jan jeweils):")
|
||||
for yr, inc in info["increases"].items():
|
||||
print(f" {yr}: {inc*100:.1f}%")
|
||||
print(f" Cells written: {info['cells']}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,279 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Add Tantieme driver + founder bonus logic + explanation text to Sensitivity/Cohort.
|
||||
|
||||
Changes per file (Wandeldarlehen-400k Base/Bull/Bear + Series-A):
|
||||
1. Treiber: append 'Tantieme Gründer' block (2028, 2029, 2030, default 0%).
|
||||
2. Personalkosten: modify rows 27 (Benjamin) and 28 (Sharang) for columns from
|
||||
Jan 2028 onwards — wrap base brutto in (1 + tantieme_for_year). Cols B-R
|
||||
(Aug 2026 to Dec 2027) stay untouched.
|
||||
3. Cohort-Analyse: add 'Erläuterung' block at the bottom explaining what the
|
||||
sheet shows and how to read it.
|
||||
4. Sensitivity: add 'Erläuterung' block explaining baseline column + reading.
|
||||
|
||||
Usage:
|
||||
python3 pitch-deck/scripts/add-tantieme-and-explanations.py --dry-run
|
||||
python3 pitch-deck/scripts/add-tantieme-and-explanations.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from openpyxl import load_workbook
|
||||
from openpyxl.styles import Alignment, Font
|
||||
|
||||
EXPORTS = Path(__file__).resolve().parent.parent / "exports"
|
||||
|
||||
TARGETS = [
|
||||
"Finanzplan-Series-A-Ambitioniert.xlsx",
|
||||
"Finanzplan-Wandeldarlehen-400k.xlsx",
|
||||
"Finanzplan-Wandeldarlehen-400k-Bull.xlsx",
|
||||
"Finanzplan-Wandeldarlehen-400k-Bear.xlsx",
|
||||
]
|
||||
|
||||
# Founders detected by column-A label ('Benjamin', 'Sharang') — actual row varies
|
||||
# by file (400k: rows 27/28, Series-A: rows 39/40).
|
||||
FOUNDER_NAME_HINTS = ("Benjamin", "Sharang")
|
||||
# Year when Tantieme starts (Jan)
|
||||
TANTIEME_START_YEAR = 2028
|
||||
TANTIEME_END_YEAR = 2030
|
||||
TANTIEME_DEFAULT = 0.0 # 0% by default; user sets 0.20 to 1.00
|
||||
|
||||
|
||||
COHORT_EXPLANATION = [
|
||||
"Erläuterung",
|
||||
"",
|
||||
"Was zeigt diese Tabelle? Jede Zeile entspricht einer Akquise-Kohorte (= alle",
|
||||
"Kunden, die in diesem Monat neu gewonnen wurden). Die Spalten M0, M1, M2 …",
|
||||
"zeigen, wie viele dieser Kunden nach 0, 1, 2 … Monaten noch aktiv sind.",
|
||||
"Die Werte sinken nach Akquise durch Churn (B5 = Retention pro Monat).",
|
||||
"",
|
||||
"Warum nützlich? Die Cohort-Analyse macht sichtbar, wie schnell Kunden",
|
||||
"abwandern und wie lang die durchschnittliche Customer Lifetime ist. Daraus",
|
||||
"ergibt sich der LTV: Lifetime (Monate) × ARPU × Bruttomarge.",
|
||||
"",
|
||||
"Beispiel: Bei monatlicher Churn-Rate 1,5% verbleiben nach 12 Monaten ~83%,",
|
||||
"nach 24 Monaten ~70%. Lifetime ≈ 1 / 0,015 = 67 Monate (~5,6 Jahre).",
|
||||
"Das Beobachtungsfenster ist auf 24 Monate begrenzt (B6 änderbar).",
|
||||
]
|
||||
|
||||
|
||||
SENSITIVITY_EXPLANATION = [
|
||||
"Erläuterung",
|
||||
"",
|
||||
"Was ist Spalte E? Der 'Baseline'-Wert für EBIT 2030 ($B$5 = GuV!F16). In",
|
||||
"jeder Zeile gleich, weil keine Variable verändert wird = Erwartung wenn",
|
||||
"alle Annahmen wie geplant. Wenn EBIT 2030 nicht sinnvoll erscheint, hängt",
|
||||
"das von Umsatz-, Personal- und Aufwand-Annahmen ab — siehe Treiber-Sheet.",
|
||||
"",
|
||||
"Was zeigen die Spalten -30% bis +30%? Pro Zeile (= eine Variable) wird",
|
||||
"diese eine Annahme um den genannten Prozentsatz variiert. Andere Annahmen",
|
||||
"bleiben Baseline. Ergebnis: EBIT 2030 unter dieser Variation.",
|
||||
"",
|
||||
"Beispiel: Zeile 'Monatliche Churn-Rate', Spalte '+30%'. Bedeutung: Wenn die",
|
||||
"Churn-Rate 30% schlechter ist als geplant, wie hoch wäre der EBIT? Die",
|
||||
"Differenz zur Baseline (E) = Sensitivität dieses Faktors.",
|
||||
"",
|
||||
"Wie interpretieren? Die Variable mit der größten Spannweite zwischen -30%",
|
||||
"und +30% ist am riskantesten (Tornado-Logik). 2D-Heatmap (Row 26+) zeigt",
|
||||
"kombinierte Effekte von Churn × ARPU auf LTV/CAC — gesund: > 3.",
|
||||
"",
|
||||
"Limitierung: One-at-a-Time ignoriert Wechselwirkungen (z.B. ändert sich",
|
||||
"Preis-Erhöhung → auch Churn). Für Investoren ist OAT trotzdem üblich.",
|
||||
]
|
||||
|
||||
|
||||
def add_tantieme_to_treiber(ws_t) -> dict[int, int]:
|
||||
"""Append Tantieme driver block after row 81. Returns {year: row}."""
|
||||
start = ws_t.max_row + 2 # blank then header
|
||||
r = start
|
||||
ws_t.cell(row=r, column=1).value = "Tantieme Gründer (% Brutto, ab Jan 2028)"
|
||||
r += 1
|
||||
refs: dict[int, int] = {}
|
||||
for yr in range(TANTIEME_START_YEAR, TANTIEME_END_YEAR + 1):
|
||||
ws_t.cell(row=r, column=1).value = f"Tantieme Gründer {yr}"
|
||||
ws_t.cell(row=r, column=2).value = TANTIEME_DEFAULT
|
||||
ws_t.cell(row=r, column=2).number_format = "0%"
|
||||
refs[yr] = r
|
||||
r += 1
|
||||
return refs
|
||||
|
||||
|
||||
def find_jan_2028_col(ws_pk) -> int:
|
||||
for c in range(2, ws_pk.max_column + 1):
|
||||
y = ws_pk.cell(row=1, column=c).value
|
||||
m = ws_pk.cell(row=2, column=c).value
|
||||
if y == 2028 and m == 1:
|
||||
return c
|
||||
raise RuntimeError("Could not locate Jan 2028 column in Personalkosten")
|
||||
|
||||
|
||||
# Match the base brutto subexpression we want to wrap: $D$X*(1+$E$X/100)^(<col>$1-$G$X)
|
||||
BASE_BRUTTO_RE = re.compile(
|
||||
r"(\$D\$(\d+)\*\(1\+\$E\$\2/100\)\^\(([A-Z]+)\$1-\$G\$\2\))"
|
||||
)
|
||||
|
||||
|
||||
def wrap_with_tantieme(formula: str, tantieme_first: int, tantieme_last: int) -> str | None:
|
||||
"""Wrap the base-brutto subexpression with (1 + tantieme_for_year). Returns
|
||||
the new formula, or None if no match or already wrapped (idempotent).
|
||||
"""
|
||||
# Idempotency: skip if a Tantieme multiplier with these rows already exists
|
||||
if f"INDEX(Treiber!$B${tantieme_first}:$B${tantieme_last}" in formula:
|
||||
return None
|
||||
m = BASE_BRUTTO_RE.search(formula)
|
||||
if not m:
|
||||
return None
|
||||
base_expr = m.group(1)
|
||||
col_letter = m.group(3)
|
||||
tantieme_factor = (
|
||||
f"(1+INDEX(Treiber!$B${tantieme_first}:$B${tantieme_last},"
|
||||
f"{col_letter}$1-{TANTIEME_START_YEAR - 1}))"
|
||||
)
|
||||
new_expr = f"{base_expr}*{tantieme_factor}"
|
||||
return formula.replace(base_expr, new_expr)
|
||||
|
||||
|
||||
def find_founder_brutto_rows(ws_pk) -> list[int]:
|
||||
rows: list[int] = []
|
||||
for r in range(1, ws_pk.max_row + 1):
|
||||
a = ws_pk.cell(row=r, column=1).value
|
||||
if not isinstance(a, str):
|
||||
continue
|
||||
if "— Brutto" in a and any(hint in a for hint in FOUNDER_NAME_HINTS):
|
||||
rows.append(r)
|
||||
return rows
|
||||
|
||||
|
||||
def apply_tantieme_to_personalkosten(ws_pk, refs: dict[int, int]) -> int:
|
||||
jan_2028 = find_jan_2028_col(ws_pk)
|
||||
tantieme_first = min(refs.values())
|
||||
tantieme_last = max(refs.values())
|
||||
rows = find_founder_brutto_rows(ws_pk)
|
||||
n = 0
|
||||
for row in rows:
|
||||
for c in range(jan_2028, ws_pk.max_column + 1):
|
||||
cell = ws_pk.cell(row=row, column=c)
|
||||
if not isinstance(cell.value, str) or not cell.value.startswith("="):
|
||||
continue
|
||||
new_formula = wrap_with_tantieme(cell.value, tantieme_first, tantieme_last)
|
||||
if new_formula is not None:
|
||||
cell.value = new_formula
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
def add_explanation_block(ws, lines: list[str], start_row: int) -> int:
|
||||
"""Write lines starting at start_row, col A. Returns last row written."""
|
||||
bold = Font(bold=True, size=12)
|
||||
wrap = Alignment(wrap_text=True, vertical="top")
|
||||
r = start_row
|
||||
for line in lines:
|
||||
cell = ws.cell(row=r, column=1, value=line)
|
||||
if line == "Erläuterung":
|
||||
cell.font = bold
|
||||
else:
|
||||
cell.alignment = wrap
|
||||
r += 1
|
||||
return r - 1
|
||||
|
||||
|
||||
def process_file(path: Path, dry_run: bool) -> dict:
|
||||
wb = load_workbook(path)
|
||||
stats: dict = {}
|
||||
|
||||
# 1 — Tantieme driver in Treiber
|
||||
if "Treiber" not in wb.sheetnames or "Personalkosten" not in wb.sheetnames:
|
||||
return {"error": "missing required sheets"}
|
||||
# Detect existing Tantieme block to avoid duplicating
|
||||
ws_t = wb["Treiber"]
|
||||
existing_row = None
|
||||
for r in range(1, ws_t.max_row + 1):
|
||||
v = ws_t.cell(row=r, column=1).value
|
||||
if isinstance(v, str) and v.startswith("Tantieme Gründer (%"):
|
||||
existing_row = r
|
||||
break
|
||||
if existing_row:
|
||||
# Already present: read existing references, don't re-append
|
||||
refs = {}
|
||||
r = existing_row + 1
|
||||
for yr in range(TANTIEME_START_YEAR, TANTIEME_END_YEAR + 1):
|
||||
refs[yr] = r
|
||||
r += 1
|
||||
stats["tantieme_block"] = "preserved"
|
||||
else:
|
||||
refs = add_tantieme_to_treiber(ws_t)
|
||||
stats["tantieme_block"] = f"added rows {min(refs.values())}-{max(refs.values())}"
|
||||
|
||||
# 2 — Apply Tantieme to founder Brutto rows
|
||||
ws_pk = wb["Personalkosten"]
|
||||
n_formulas = apply_tantieme_to_personalkosten(ws_pk, refs)
|
||||
stats["formulas_wrapped"] = n_formulas
|
||||
|
||||
# 3 — Cohort-Analyse explanation
|
||||
if "Cohort-Analyse" in wb.sheetnames:
|
||||
ws_co = wb["Cohort-Analyse"]
|
||||
# Append at row 49 (after content at row 47). Idempotent: don't re-add.
|
||||
existing = any(
|
||||
ws_co.cell(row=r, column=1).value == "Erläuterung"
|
||||
for r in range(48, min(ws_co.max_row + 1, 70))
|
||||
)
|
||||
if not existing:
|
||||
last = add_explanation_block(ws_co, COHORT_EXPLANATION, start_row=49)
|
||||
stats["cohort_explanation"] = f"rows 49-{last}"
|
||||
else:
|
||||
stats["cohort_explanation"] = "already present"
|
||||
|
||||
# 4 — Sensitivity explanation
|
||||
if "Sensitivity" in wb.sheetnames:
|
||||
ws_se = wb["Sensitivity"]
|
||||
existing = any(
|
||||
ws_se.cell(row=r, column=1).value == "Erläuterung"
|
||||
for r in range(38, min(ws_se.max_row + 1, 70))
|
||||
)
|
||||
if not existing:
|
||||
last = add_explanation_block(ws_se, SENSITIVITY_EXPLANATION, start_row=39)
|
||||
stats["sensitivity_explanation"] = f"rows 39-{last}"
|
||||
else:
|
||||
stats["sensitivity_explanation"] = "already present"
|
||||
|
||||
if not dry_run:
|
||||
wb.save(path)
|
||||
return stats
|
||||
|
||||
|
||||
def backup(path: Path) -> Path:
|
||||
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
bk = path.with_name(f"{path.stem}.BACKUP-pre-tantieme-{ts}{path.suffix}")
|
||||
shutil.copy2(path, bk)
|
||||
return bk
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("--dry-run", action="store_true")
|
||||
ap.add_argument("--no-backup", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
for name in TARGETS:
|
||||
path = EXPORTS / name
|
||||
if not path.exists():
|
||||
print(f" ⨯ skip {name}: not found")
|
||||
continue
|
||||
if not args.dry_run and not args.no_backup:
|
||||
bk = backup(path)
|
||||
print(f" ✓ backup: {bk.name}")
|
||||
stats = process_file(path, dry_run=args.dry_run)
|
||||
print(f"\n === {name} ===")
|
||||
for k, v in stats.items():
|
||||
print(f" {k}: {v}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Apply 'Büromiete ab Sep 2027 → 1000€/Monat' to Finanzplan-Wandeldarlehen-400k
|
||||
Base/Bull/Bear scenarios.
|
||||
|
||||
Updates row 27 (raumkosten) in 'Betriebliche Aufwendungen' sheet of each file.
|
||||
Existing label is renamed to 'Büromiete (ab Sep 2027)'.
|
||||
|
||||
Usage:
|
||||
python3 pitch-deck/scripts/apply-bueromiete.py
|
||||
python3 pitch-deck/scripts/apply-bueromiete.py --dry-run
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import shutil
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from openpyxl import load_workbook
|
||||
from openpyxl.utils import get_column_letter
|
||||
|
||||
EXPORTS = Path(__file__).resolve().parent.parent / "exports"
|
||||
|
||||
TARGETS = [
|
||||
"Finanzplan-Wandeldarlehen-400k.xlsx",
|
||||
"Finanzplan-Wandeldarlehen-400k-Bull.xlsx",
|
||||
"Finanzplan-Wandeldarlehen-400k-Bear.xlsx",
|
||||
]
|
||||
|
||||
RENT_ROW = 27
|
||||
RENT_AMOUNT = 1000
|
||||
START_YEAR = 2027
|
||||
START_MONTH = 9
|
||||
NEW_LABEL = "Büromiete (ab Sep 2027)"
|
||||
|
||||
|
||||
def find_start_col(ws) -> int:
|
||||
for c in range(2, ws.max_column + 1):
|
||||
if ws.cell(row=1, column=c).value == START_YEAR and ws.cell(row=2, column=c).value == START_MONTH:
|
||||
return c
|
||||
raise RuntimeError(f"Could not find {START_MONTH}/{START_YEAR} in header rows")
|
||||
|
||||
|
||||
def process_file(path: Path, dry_run: bool) -> tuple[int, int, int]:
|
||||
wb = load_workbook(path)
|
||||
if "Betriebliche Aufwendungen" not in wb.sheetnames:
|
||||
raise RuntimeError(f"{path.name}: missing 'Betriebliche Aufwendungen' sheet")
|
||||
ws = wb["Betriebliche Aufwendungen"]
|
||||
|
||||
current_label = ws.cell(row=RENT_ROW, column=1).value
|
||||
if current_label is None or "raumkosten" not in str(current_label).lower() and "raum" not in str(current_label).lower() and "miete" not in str(current_label).lower() and "büro" not in str(current_label).lower():
|
||||
raise RuntimeError(f"{path.name}: row {RENT_ROW} label is {current_label!r}, expected raumkosten/raum/miete/büro")
|
||||
|
||||
ws.cell(row=RENT_ROW, column=1).value = NEW_LABEL
|
||||
|
||||
start_col = find_start_col(ws)
|
||||
end_col = ws.max_column
|
||||
for c in range(start_col, end_col + 1):
|
||||
ws.cell(row=RENT_ROW, column=c).value = RENT_AMOUNT
|
||||
|
||||
if not dry_run:
|
||||
wb.save(path)
|
||||
|
||||
return start_col, end_col, end_col - start_col + 1
|
||||
|
||||
|
||||
def backup(path: Path) -> Path:
|
||||
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
bk = path.with_name(f"{path.stem}.BACKUP-pre-bueromiete-{ts}{path.suffix}")
|
||||
shutil.copy2(path, bk)
|
||||
return bk
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("--dry-run", action="store_true")
|
||||
ap.add_argument("--no-backup", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
for name in TARGETS:
|
||||
path = EXPORTS / name
|
||||
if not path.exists():
|
||||
print(f" ⚠ skip (not found): {name}", file=sys.stderr)
|
||||
continue
|
||||
if not args.dry_run and not args.no_backup:
|
||||
bk = backup(path)
|
||||
print(f" ✓ backup: {bk.name}")
|
||||
start_col, end_col, n = process_file(path, dry_run=args.dry_run)
|
||||
print(
|
||||
f" {name}: row {RENT_ROW} → '{NEW_LABEL}', "
|
||||
f"cols {get_column_letter(start_col)}..{get_column_letter(end_col)} = {RENT_AMOUNT}€ ({n} months)"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,219 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Apply Euro / Count / Percent number formatting to Finanzplan Excel files.
|
||||
|
||||
Per-sheet defaults + per-row classification based on column-A labels:
|
||||
|
||||
- Sheets with mostly EUR values: Dashboard, Umsatzerlöse, Personalkosten,
|
||||
Investitionen, Materialaufwand, Betriebliche Aufwendungen, Liquidität, GuV.
|
||||
- Kunden sheet: counts by default, with helper rows (rates) as percent.
|
||||
- Treiber sheet: row-by-row classification by label (no sheet default).
|
||||
- Skip: Formelübersicht (docs).
|
||||
|
||||
Label patterns drive the classification:
|
||||
- 'EUR/', 'EUR ', 'Startpreis', 'Preis/Monat' → euro
|
||||
- 'rate', 'satz', 'quote', 'inflation', 'erhöhung', 'provision', '% vom',
|
||||
'anteil', 'förderquote' → percent
|
||||
- 'headcount', 'anzahl', 'mitarbeiter je', 'neukunden/monat', 'neukunden ',
|
||||
'bestandskunden', 'churn ' → count
|
||||
- 'faktor' → skip (it's a multiplier, leave default)
|
||||
|
||||
Inputs sections (Personalkosten rows 5-24, Investitionen 5-29) are skipped
|
||||
because they contain mixed text/dates/numbers per row that would mis-format
|
||||
under a single classification.
|
||||
|
||||
Usage:
|
||||
python3 pitch-deck/scripts/apply-number-formatting.py --dry-run
|
||||
python3 pitch-deck/scripts/apply-number-formatting.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import shutil
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from openpyxl import load_workbook
|
||||
|
||||
EXPORTS = Path(__file__).resolve().parent.parent / "exports"
|
||||
|
||||
EURO_FORMAT = '#,##0 "€";-#,##0 "€"'
|
||||
COUNT_FORMAT = '#,##0'
|
||||
PERCENT_FORMAT = '0.0%'
|
||||
|
||||
SHEET_CONFIG: dict[str, dict] = {
|
||||
"Dashboard": {"default": "euro", "start_row": 4},
|
||||
"Umsatzerlöse": {"default": "euro", "start_row": 4},
|
||||
"Personalkosten": {"default": "euro", "start_row": 27},
|
||||
"Investitionen": {"default": "euro", "start_row": 31},
|
||||
"Materialaufwand": {"default": "euro", "start_row": 4},
|
||||
"Betriebliche Aufwendungen":{"default": "euro", "start_row": 4},
|
||||
"Liquidität": {"default": "euro", "start_row": 4},
|
||||
"GuV": {"default": "euro", "start_row": 2},
|
||||
"Kunden": {"default": "count", "start_row": 4},
|
||||
"Treiber": {"default": None, "start_row": 1},
|
||||
}
|
||||
|
||||
SKIP_SHEETS = {"Formelübersicht"}
|
||||
|
||||
FORMAT_MAP = {"euro": EURO_FORMAT, "count": COUNT_FORMAT, "percent": PERCENT_FORMAT}
|
||||
|
||||
|
||||
def classify_kunden_row(label: str | None) -> str:
|
||||
"""Kunden sheet is always counts/rates regardless of stray 'EUR' substrings."""
|
||||
if not label:
|
||||
return "skip"
|
||||
s = str(label).lower()
|
||||
if "rate" in s or "helper" in s:
|
||||
return "percent"
|
||||
return "count"
|
||||
|
||||
|
||||
def classify_label(label: str | None, sheet_default: str | None) -> str:
|
||||
if not label:
|
||||
return "skip"
|
||||
s = str(label).lower()
|
||||
|
||||
# 1. Explicit Euro markers
|
||||
if any(k in s for k in ("eur/", " eur ", "eur ", " eur", "startpreis", "preis/monat", "preis (")):
|
||||
return "euro"
|
||||
|
||||
# 2. Multipliers — skip (preserve existing format)
|
||||
if "faktor" in s:
|
||||
return "skip"
|
||||
|
||||
# 3. Percent patterns. Use precise tokens to avoid substring traps:
|
||||
# 'satz' alone matches 'Umsatz' — use '-satz' / 'steuersatz' instead.
|
||||
# 'inflation' as substring matches 'Büromiete (+Inflation)' annotation —
|
||||
# require the label to START with 'inflation' (covers 'Inflation 2027' driver rows).
|
||||
if s.startswith("inflation"):
|
||||
return "percent"
|
||||
# Note: 'provision' alone is too broad — it matches the BA channel-partner
|
||||
# provision row whose value is in EUR. Use 'anteil' (matches Treiber's
|
||||
# 'Channel-Provision (Anteil vom Umsatz)') instead.
|
||||
if any(k in s for k in ("-rate", "rate ", "rate(", "-satz", "steuersatz",
|
||||
"quote", "erhöhung",
|
||||
"% vom", "anteil")):
|
||||
return "percent"
|
||||
|
||||
# 4. Count patterns
|
||||
if any(k in s for k in ("headcount", "anzahl", "mitarbeiter je",
|
||||
"/monat starter", "/monat professional", "/monat enterprise")):
|
||||
return "count"
|
||||
|
||||
# 5. Kunden-sheet customer-tracking rows
|
||||
if s.startswith(("neukunden", "churn ", "bestandskunden")):
|
||||
return "count"
|
||||
|
||||
if sheet_default:
|
||||
return sheet_default
|
||||
return "skip"
|
||||
|
||||
|
||||
def cell_is_numeric_or_formula(value) -> bool:
|
||||
if value is None:
|
||||
return True
|
||||
if isinstance(value, (int, float)):
|
||||
return True
|
||||
if isinstance(value, str):
|
||||
return value.startswith("=")
|
||||
return False
|
||||
|
||||
|
||||
def format_sheet(ws, sheet_name: str) -> dict[str, int]:
|
||||
config = SHEET_CONFIG.get(sheet_name)
|
||||
if config is None:
|
||||
return {"euro": 0, "count": 0, "percent": 0, "skipped_rows": 0}
|
||||
start_row = config["start_row"]
|
||||
sheet_default = config["default"]
|
||||
stats = {"euro": 0, "count": 0, "percent": 0, "skipped_rows": 0}
|
||||
|
||||
for r in range(start_row, ws.max_row + 1):
|
||||
label = ws.cell(row=r, column=1).value
|
||||
if sheet_name == "Kunden":
|
||||
kind = classify_kunden_row(label)
|
||||
else:
|
||||
kind = classify_label(label, sheet_default)
|
||||
if kind == "skip":
|
||||
stats["skipped_rows"] += 1
|
||||
continue
|
||||
fmt = FORMAT_MAP[kind]
|
||||
|
||||
# Treiber: value lives in col B only (apply to row's col B)
|
||||
if sheet_name == "Treiber":
|
||||
cell = ws.cell(row=r, column=2)
|
||||
if cell_is_numeric_or_formula(cell.value) and cell.value is not None:
|
||||
cell.number_format = fmt
|
||||
stats[kind] += 1
|
||||
continue
|
||||
|
||||
# Other sheets: apply across all value columns
|
||||
for c in range(2, ws.max_column + 1):
|
||||
cell = ws.cell(row=r, column=c)
|
||||
if not cell_is_numeric_or_formula(cell.value):
|
||||
continue
|
||||
cell.number_format = fmt
|
||||
stats[kind] += 1
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
def process_file(path: Path, dry_run: bool) -> dict:
|
||||
wb = load_workbook(path)
|
||||
sheet_stats: dict[str, dict] = {}
|
||||
for sheet_name in wb.sheetnames:
|
||||
if sheet_name in SKIP_SHEETS:
|
||||
continue
|
||||
if sheet_name not in SHEET_CONFIG:
|
||||
continue
|
||||
sheet_stats[sheet_name] = format_sheet(wb[sheet_name], sheet_name)
|
||||
|
||||
if not dry_run:
|
||||
wb.save(path)
|
||||
return sheet_stats
|
||||
|
||||
|
||||
def backup(path: Path) -> Path:
|
||||
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
bk = path.with_name(f"{path.stem}.BACKUP-pre-formatting-{ts}{path.suffix}")
|
||||
shutil.copy2(path, bk)
|
||||
return bk
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("--dry-run", action="store_true")
|
||||
ap.add_argument("--only", help="Process only this filename")
|
||||
ap.add_argument("--no-backup", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
files = sorted(EXPORTS.glob("Finanzplan-*.xlsx"))
|
||||
files = [f for f in files if "BACKUP" not in f.name]
|
||||
if args.only:
|
||||
files = [f for f in files if f.name == args.only]
|
||||
|
||||
for path in files:
|
||||
if not args.dry_run and not args.no_backup:
|
||||
bk = backup(path)
|
||||
print(f" ✓ backup: {bk.name}")
|
||||
stats = process_file(path, dry_run=args.dry_run)
|
||||
print(f"\n === {path.name} ===")
|
||||
for sheet, s in stats.items():
|
||||
total = s["euro"] + s["count"] + s["percent"]
|
||||
if total > 0 or s["skipped_rows"] > 0:
|
||||
parts = []
|
||||
if s["euro"]:
|
||||
parts.append(f"€:{s['euro']}")
|
||||
if s["count"]:
|
||||
parts.append(f"#:{s['count']}")
|
||||
if s["percent"]:
|
||||
parts.append(f"%:{s['percent']}")
|
||||
if s["skipped_rows"]:
|
||||
parts.append(f"skip:{s['skipped_rows']}")
|
||||
print(f" {sheet}: {' '.join(parts)}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Clean up Finanzplan Excel files:
|
||||
|
||||
Strip 'category — ' prefix from column-A labels in 'Betriebliche Aufwendungen'
|
||||
and 'Liquidität' sheets.
|
||||
|
||||
Runs against ALL Finanzplan-*.xlsx files in pitch-deck/exports/ (except BACKUPs).
|
||||
Creates a single timestamped backup per file before modification.
|
||||
|
||||
Usage:
|
||||
python3 pitch-deck/scripts/cleanup-finanzplan-labels.py
|
||||
python3 pitch-deck/scripts/cleanup-finanzplan-labels.py --dry-run
|
||||
python3 pitch-deck/scripts/cleanup-finanzplan-labels.py --only Finanzplan-Wandeldarlehen-400k.xlsx
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import shutil
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from openpyxl import load_workbook
|
||||
|
||||
EXPORTS = Path(__file__).resolve().parent.parent / "exports"
|
||||
|
||||
|
||||
def strip_prefix(value):
|
||||
"""Drop a lowercase 'kategorie — ' prefix from a label."""
|
||||
if not value:
|
||||
return value
|
||||
s = str(value)
|
||||
if " — " not in s:
|
||||
return s
|
||||
prefix, rest = s.split(" — ", 1)
|
||||
# Only strip if prefix looks like a single-word lowercase category tag
|
||||
if prefix.islower() and prefix.replace("/", "").replace("-", "").isalpha():
|
||||
return rest
|
||||
return s
|
||||
|
||||
|
||||
def clean_labels(ws, start_row: int = 4) -> int:
|
||||
n = 0
|
||||
for r in range(start_row, ws.max_row + 1):
|
||||
before = ws.cell(row=r, column=1).value
|
||||
after = strip_prefix(before)
|
||||
if after != before:
|
||||
ws.cell(row=r, column=1).value = after
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
def process_file(path: Path, dry_run: bool = False) -> dict:
|
||||
wb = load_workbook(path)
|
||||
stats = {"liq_labels": 0, "ba_labels": 0}
|
||||
|
||||
if "Liquidität" in wb.sheetnames:
|
||||
stats["liq_labels"] = clean_labels(wb["Liquidität"], start_row=4)
|
||||
|
||||
if "Betriebliche Aufwendungen" in wb.sheetnames:
|
||||
stats["ba_labels"] = clean_labels(wb["Betriebliche Aufwendungen"], start_row=4)
|
||||
|
||||
if not dry_run:
|
||||
wb.save(path)
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
def backup(path: Path, tag: str) -> Path:
|
||||
"""Make a single timestamped backup. Returns the backup path."""
|
||||
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
bk = path.with_name(f"{path.stem}.BACKUP-{tag}-{ts}{path.suffix}")
|
||||
shutil.copy2(path, bk)
|
||||
return bk
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("--dry-run", action="store_true", help="Show what would change, do not write")
|
||||
ap.add_argument("--only", help="Process only a specific filename")
|
||||
ap.add_argument("--no-backup", action="store_true", help="Skip backup (default: backup once)")
|
||||
args = ap.parse_args()
|
||||
|
||||
files = sorted(EXPORTS.glob("Finanzplan-*.xlsx"))
|
||||
files = [f for f in files if "BACKUP" not in f.name]
|
||||
if args.only:
|
||||
files = [f for f in files if f.name == args.only]
|
||||
if not files:
|
||||
print("No files matched", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(f"{'DRY-RUN — ' if args.dry_run else ''}{len(files)} file(s) to process")
|
||||
for path in files:
|
||||
if not args.dry_run and not args.no_backup:
|
||||
bk = backup(path, "pre-label-cleanup")
|
||||
print(f" ✓ backup: {bk.name}")
|
||||
stats = process_file(path, dry_run=args.dry_run)
|
||||
print(
|
||||
f" {path.name}: "
|
||||
f"Liq labels={stats['liq_labels']} BA labels={stats['ba_labels']}"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,225 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Copy 'extra' sheets from the Series-A Ambitioniert reference workbook to the
|
||||
400k Wandeldarlehen Base/Bull/Bear workbooks.
|
||||
|
||||
Source: Finanzplan-Series-A-Ambitioniert.xlsx
|
||||
Sheets copied:
|
||||
- Charts (153 rows + 12 chart objects)
|
||||
- Unit Economics (Investor-KPIs incl. LTV/CAC, NRR)
|
||||
- Wandeldarlehen (Tranchen-Konditionen)
|
||||
- Cohort-Analyse (Retention pro Akquise-Monat)
|
||||
- Sensitivity (Sensitivity-Analyse)
|
||||
- Hiring-Plan (Quartal-Übersicht)
|
||||
|
||||
Also renames 'Net Dollar Retention' → 'Net Revenue Retention' and 'NDR' → 'NRR'
|
||||
across all target files (and the source), since we calculate in Euro.
|
||||
|
||||
Cell values, number formats, basic styles, merged cells, column widths, row
|
||||
heights, and freeze panes are copied. Charts are deep-copied — they may fail
|
||||
silently if the openpyxl chart structure isn't fully serializable; a warning
|
||||
is printed in that case.
|
||||
|
||||
Usage:
|
||||
python3 pitch-deck/scripts/copy-extra-sheets.py --dry-run
|
||||
python3 pitch-deck/scripts/copy-extra-sheets.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import shutil
|
||||
import sys
|
||||
from copy import copy as shallow_copy
|
||||
from copy import deepcopy
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from openpyxl import load_workbook
|
||||
|
||||
EXPORTS = Path(__file__).resolve().parent.parent / "exports"
|
||||
|
||||
SOURCE_FILE = "Finanzplan-Series-A-Ambitioniert.xlsx"
|
||||
|
||||
TARGETS = [
|
||||
"Finanzplan-Wandeldarlehen-400k.xlsx",
|
||||
"Finanzplan-Wandeldarlehen-400k-Bull.xlsx",
|
||||
"Finanzplan-Wandeldarlehen-400k-Bear.xlsx",
|
||||
]
|
||||
|
||||
SHEETS_TO_COPY = [
|
||||
"Charts",
|
||||
"Unit Economics",
|
||||
"Wandeldarlehen",
|
||||
"Cohort-Analyse",
|
||||
"Sensitivity",
|
||||
"Hiring-Plan",
|
||||
]
|
||||
|
||||
# Currency-neutral renames (Euro project, not USD)
|
||||
RENAMES = {
|
||||
"Net Dollar Retention": "Net Revenue Retention",
|
||||
"NDR Annahme": "NRR Annahme",
|
||||
"• NDR steigt": "• NRR steigt",
|
||||
"NDR": "NRR", # last; safety renames any remaining bare 'NDR'
|
||||
}
|
||||
|
||||
|
||||
def copy_cell_style(src_cell, dst_cell) -> None:
|
||||
if src_cell.has_style:
|
||||
dst_cell.font = shallow_copy(src_cell.font)
|
||||
dst_cell.fill = shallow_copy(src_cell.fill)
|
||||
dst_cell.border = shallow_copy(src_cell.border)
|
||||
dst_cell.alignment = shallow_copy(src_cell.alignment)
|
||||
dst_cell.number_format = src_cell.number_format
|
||||
dst_cell.protection = shallow_copy(src_cell.protection)
|
||||
|
||||
|
||||
def copy_sheet_content(src_ws, dst_ws) -> None:
|
||||
"""Copy cells, dimensions, merged ranges, freeze panes, and charts."""
|
||||
for row in src_ws.iter_rows():
|
||||
for src_cell in row:
|
||||
dst_cell = dst_ws.cell(row=src_cell.row, column=src_cell.column, value=src_cell.value)
|
||||
copy_cell_style(src_cell, dst_cell)
|
||||
|
||||
# Column widths + hidden state
|
||||
for col_key, dim in src_ws.column_dimensions.items():
|
||||
d = dst_ws.column_dimensions[col_key]
|
||||
if dim.width:
|
||||
d.width = dim.width
|
||||
d.hidden = dim.hidden
|
||||
|
||||
# Row heights + hidden state
|
||||
for row_key, dim in src_ws.row_dimensions.items():
|
||||
d = dst_ws.row_dimensions[row_key]
|
||||
if dim.height:
|
||||
d.height = dim.height
|
||||
d.hidden = dim.hidden
|
||||
|
||||
# Merged cells
|
||||
for merged_range in list(src_ws.merged_cells.ranges):
|
||||
dst_ws.merge_cells(str(merged_range))
|
||||
|
||||
# Freeze panes
|
||||
if src_ws.freeze_panes:
|
||||
dst_ws.freeze_panes = src_ws.freeze_panes
|
||||
|
||||
# Charts (best-effort deepcopy)
|
||||
charts_ok = 0
|
||||
charts_fail = 0
|
||||
for chart in src_ws._charts:
|
||||
try:
|
||||
new_chart = deepcopy(chart)
|
||||
dst_ws.add_chart(new_chart)
|
||||
charts_ok += 1
|
||||
except Exception as e: # noqa: BLE001
|
||||
charts_fail += 1
|
||||
print(f" ⚠ chart copy failed: {type(e).__name__}: {e}", file=sys.stderr)
|
||||
if charts_ok or charts_fail:
|
||||
print(f" charts: {charts_ok} ok, {charts_fail} failed")
|
||||
|
||||
|
||||
def insert_sheet_before_formeluebersicht(wb, sheet_name: str) -> None:
|
||||
"""If 'Formelübersicht' is in the workbook, move new sheet just before it."""
|
||||
if "Formelübersicht" not in wb.sheetnames:
|
||||
return
|
||||
new_idx = wb.sheetnames.index(sheet_name)
|
||||
fmt_idx = wb.sheetnames.index("Formelübersicht")
|
||||
if new_idx > fmt_idx:
|
||||
# move new sheet to just before Formelübersicht
|
||||
offset = (fmt_idx) - new_idx
|
||||
wb.move_sheet(sheet_name, offset=offset)
|
||||
|
||||
|
||||
def rename_in_workbook(wb) -> int:
|
||||
"""Apply NDR → NRR renames across all string cells. Returns count of changes."""
|
||||
n = 0
|
||||
for ws in wb.worksheets:
|
||||
for row in ws.iter_rows():
|
||||
for cell in row:
|
||||
if not isinstance(cell.value, str) or not cell.value:
|
||||
continue
|
||||
new_value = cell.value
|
||||
for old, new in RENAMES.items():
|
||||
if old in new_value:
|
||||
new_value = new_value.replace(old, new)
|
||||
if new_value != cell.value:
|
||||
cell.value = new_value
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
def process_target(target_path: Path, src_wb, dry_run: bool) -> dict:
|
||||
wb = load_workbook(target_path)
|
||||
copied_sheets = []
|
||||
for sheet_name in SHEETS_TO_COPY:
|
||||
if sheet_name not in src_wb.sheetnames:
|
||||
print(f" skip {sheet_name}: not in source")
|
||||
continue
|
||||
if sheet_name in wb.sheetnames:
|
||||
del wb[sheet_name]
|
||||
new_ws = wb.create_sheet(title=sheet_name)
|
||||
print(f" copying {sheet_name} ...")
|
||||
copy_sheet_content(src_wb[sheet_name], new_ws)
|
||||
insert_sheet_before_formeluebersicht(wb, sheet_name)
|
||||
copied_sheets.append(sheet_name)
|
||||
|
||||
renames = rename_in_workbook(wb)
|
||||
|
||||
if not dry_run:
|
||||
wb.save(target_path)
|
||||
|
||||
return {"copied": copied_sheets, "renames": renames}
|
||||
|
||||
|
||||
def backup(path: Path) -> Path:
|
||||
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
bk = path.with_name(f"{path.stem}.BACKUP-pre-extra-sheets-{ts}{path.suffix}")
|
||||
shutil.copy2(path, bk)
|
||||
return bk
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("--dry-run", action="store_true")
|
||||
ap.add_argument("--no-backup", action="store_true")
|
||||
ap.add_argument("--skip-rename", action="store_true",
|
||||
help="Don't apply NDR → NRR renames")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.skip_rename:
|
||||
RENAMES.clear()
|
||||
|
||||
source_path = EXPORTS / SOURCE_FILE
|
||||
if not source_path.exists():
|
||||
print(f"ERROR: source not found: {source_path}", file=sys.stderr)
|
||||
return 2
|
||||
src_wb = load_workbook(source_path)
|
||||
|
||||
# Also rename in source
|
||||
if not args.dry_run:
|
||||
bk_src = backup(source_path)
|
||||
print(f" ✓ source backup: {bk_src.name}")
|
||||
n_src_renames = rename_in_workbook(src_wb)
|
||||
if not args.dry_run:
|
||||
src_wb.save(source_path)
|
||||
print(f" source ({SOURCE_FILE}): {n_src_renames} cell rename(s)")
|
||||
# Re-open source as the now-renamed version so target copies pick up new labels
|
||||
src_wb = load_workbook(source_path)
|
||||
|
||||
for name in TARGETS:
|
||||
path = EXPORTS / name
|
||||
if not path.exists():
|
||||
print(f"\n ⨯ skip {name}: not found")
|
||||
continue
|
||||
if not args.dry_run and not args.no_backup:
|
||||
bk = backup(path)
|
||||
print(f"\n ✓ backup: {bk.name}")
|
||||
print(f" === {name} ===")
|
||||
info = process_target(path, src_wb, dry_run=args.dry_run)
|
||||
print(f" copied: {info['copied']}")
|
||||
print(f" cell renames: {info['renames']}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user