Compare commits
262 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 487dc6d1e7 | |||
| 8b7671d310 | |||
| 24e57f558e | |||
| c4ad3bc2c4 | |||
| fa6b0a241d | |||
| 82c9b5cf53 | |||
| bb1144f392 | |||
| 48c6f9277c | |||
| 56da89fb0e | |||
| 8442ac82f1 | |||
| 283894a197 | |||
| 41c2191280 | |||
| f3dba93d81 | |||
| 62aa56b007 | |||
| 72250c7c75 | |||
| 2dfc47d67e | |||
| 798c2c4373 | |||
| e97c03587d | |||
| 004a624f23 | |||
| 15b6e8614c | |||
| 80376c90b3 | |||
| 111e5d546f | |||
| 43418d46fd | |||
| e4f2d49e96 | |||
| 898ad1785b | |||
| 8aa5db39fd | |||
| db0b77ef8f | |||
| 0d123d8264 | |||
| 4abba96515 | |||
| c49fae8776 | |||
| 9a750eb2b1 | |||
| 4c2a7574e4 | |||
| f115b0a307 | |||
| c34c06d28d | |||
| 48042bde47 | |||
| 7fb207cfce | |||
| 11b330c268 | |||
| fb53c8be90 | |||
| b29dc33708 | |||
| 7cb79dacd5 | |||
| 14362cbc0e | |||
| 91f4202e88 | |||
| e5bb8e65e8 | |||
| f86dc265eb | |||
| ae31a19275 | |||
| e72b68b4a3 | |||
| 6b08ce6b6a | |||
| e0a3ff5ca9 | |||
| b3baf603ee | |||
| ad2bbab7b6 | |||
| a3a1ec4430 | |||
| c0c44adaaa | |||
| 8a4e196864 | |||
| 34e2614e36 | |||
| ac8ef371ff | |||
| e37aecab18 | |||
| ecc1423a4f | |||
| 39fcf58d1b | |||
| 497be5fac9 | |||
| 855e764911 | |||
| e151984ce2 | |||
| 11431bbf4e | |||
| fcac514d9f | |||
| da4dcdca32 | |||
| 3cce3b2871 | |||
| a2c5307713 | |||
| ef7ec776eb | |||
| 9fd829eceb | |||
| 96cd79dec5 | |||
| fde1673bdd | |||
| f781874eee | |||
| 9333b7a9c3 | |||
| 6db0056329 | |||
| e0fedde560 | |||
| f7441ccba5 | |||
| 27d1c5ba9f | |||
| 8354ab4df4 | |||
| 88d0619184 | |||
| 6111494460 | |||
| 73e3749960 | |||
| f57bdfa151 | |||
| 34b519eebb | |||
| 66fb265f22 | |||
| ec7326cfe1 | |||
| 67ed5e542d | |||
| 6ec27fdbf2 | |||
| 9513675d85 | |||
| 8e2329be53 | |||
| 19214bfd66 | |||
| 53e61c6dcd | |||
| 728f698f9e | |||
| 511a7de627 | |||
| 9d82f15c53 | |||
| b0918fd946 | |||
| 7b31b462a0 | |||
| 021faedfa3 | |||
| 0b30c5e66c | |||
| 824f8a7ff2 | |||
| 5914ec6cd5 | |||
| 30c63bbef6 | |||
| 7be1a296c6 | |||
| e524786ac0 | |||
| 0ee2b1538a | |||
| dd6e2f8bd7 | |||
| f66f32ee9d | |||
| de308b7397 | |||
| 8402e57323 | |||
| 1212f6ddfb | |||
| ac2299226a | |||
| 607dab4f26 | |||
| 3b8f9b595e | |||
| 84a0280c52 | |||
| dc36e59d17 | |||
| 9bb689b7e6 | |||
| d01a50a4b1 | |||
| 51e75187ed | |||
| e37fd3bbe4 | |||
| 11fa490599 | |||
| 27ef21a4f0 | |||
| b3643ddee9 | |||
| 68b7660ce3 | |||
| 2d61911d98 | |||
| 9f642901ab | |||
| add7400b78 | |||
| 65cc5200ea | |||
| ede93a7774 | |||
| bc020e9f64 | |||
| bad4659d5b | |||
| e3b33ef596 | |||
| 39255f2c9e | |||
| 030991cb9a | |||
| fa9b554f50 | |||
| 788714ecec | |||
| 08ca17c876 | |||
| c157e9cbca | |||
| 9005a05bd7 | |||
| 98081ae5eb | |||
| c99e35438c | |||
| 1241a14ea5 | |||
| 0712d18824 | |||
| 71040dcd33 | |||
| 0923d9b051 | |||
| 909301a4de | |||
| d548ce4199 | |||
| 0188a46afb | |||
| d6be61cdcf | |||
| 6e6525a416 | |||
| 6a6b3e8cee | |||
| 09ac22f692 | |||
| 5a476ac97d | |||
| 4f2a963834 | |||
| aa7bd79c51 | |||
| 7701a34d7f | |||
| d35e3f4705 | |||
| 5d71a371d6 | |||
| f75aef2a4a | |||
| 5264528940 | |||
| 084183f3a4 | |||
| e05d3e1554 | |||
| 06f868abeb | |||
| aed428312f | |||
| 32851ca9fb | |||
| cbee0b534f | |||
| 8f44d907a5 | |||
| 24ce8ccd20 | |||
| 786993d8ca | |||
| 2b9788bdb0 | |||
| 91b5ce990f | |||
| 936b4ccc51 | |||
| 9e3f15ce4e | |||
| 7523f47468 | |||
| 6de8b33dd1 | |||
| 79c01c85fa | |||
| 735cab2018 | |||
| b4e8b74afb | |||
| 4b06933576 | |||
| 89a6b90ca6 | |||
| f9b9cf0383 | |||
| 2de4d03d81 | |||
| d2c2fd92cc | |||
| 032df7f401 | |||
| 474f09ce88 | |||
| e920dd1b3f | |||
| 5ddf8bbc3c | |||
| 14cde7b3ee | |||
| 581162cdb8 | |||
| dc27fc5500 | |||
| 51649c874b | |||
| 4d7836540a | |||
| 3419e18d7f | |||
| a9b71b9d23 | |||
| e8a18c0025 | |||
| 3e9a988aaf | |||
| 01f05e4399 | |||
| 7c17e484c1 | |||
| ea39418738 | |||
| 7f88ed0ed2 | |||
| 44659a9dd7 | |||
| 87d7da0198 | |||
| 9675c1f896 | |||
| 9736476a0c | |||
| 03d420c984 | |||
| 6b52719079 | |||
| a5b7d62969 | |||
| ef9e3699b2 | |||
| 440367b69d | |||
| 801a5a43f5 | |||
| 9c23068a4f | |||
| d359b7b734 | |||
| bd37ff807e | |||
| 40d2342086 | |||
| adf3bf8301 | |||
| 1b5ccd4dec | |||
| b5d8f9aed3 | |||
| c8171b0a1e | |||
| 7e15ef3725 | |||
| e3a3802f5b | |||
| 93e319e9fb | |||
| 6626d2a8f9 | |||
| 3dbc470158 | |||
| e5d0386cfb | |||
| ff071af2a0 | |||
| fcdcbc51e3 | |||
| 7b8f8d4b5a | |||
| f385c612f5 | |||
| 9166d9dade | |||
| 7ae5bc0fd5 | |||
| 242ed1101e | |||
| 8b2e9ac328 | |||
| 084d09e9bd | |||
| 646143ce5a | |||
| 00d802f965 | |||
| ebb7575f2c | |||
| d0539d0f2f | |||
| 8e92a93aa8 | |||
| f794347827 | |||
| 1af160eed0 | |||
| eb118ebf92 | |||
| dbb476cc3b | |||
| 9345efc3f0 | |||
| c4e993e3f8 | |||
| a58d1aa403 | |||
| d7ed5ce8c5 | |||
| 512088ab93 | |||
| 32b5e0223d | |||
| 9354cbf775 | |||
| 756d068b4f | |||
| c02a7bd8a6 | |||
| b6d3fad6ab | |||
| 27479ee553 | |||
| 82a5d62f44 | |||
| bc23c6815a | |||
| 7dd2dc89a9 | |||
| 57462899f6 | |||
| f23b872c54 | |||
| 55f7195edd | |||
| b14be8583d | |||
| 67ad7c236b | |||
| ea752088f6 | |||
| edadf39445 | |||
| 1c3cec2c06 | |||
| 746daaef6d |
@@ -0,0 +1,227 @@
|
||||
# AGENTS.go.md — Go Agent Rules
|
||||
|
||||
Applies to: `ai-compliance-sdk/` (Go/Gin service)
|
||||
|
||||
---
|
||||
|
||||
## NON-NEGOTIABLE: Pre-Push Checklist
|
||||
|
||||
**BEFORE every `git push`, run ALL of the following from the module root. A single failure blocks the push.**
|
||||
|
||||
```bash
|
||||
# 1. Format (gofmt is non-negotiable — unformatted code fails CI)
|
||||
gofmt -l . | grep -q . && echo "FORMATTING ERRORS — run: gofmt -w ." && exit 1 || true
|
||||
|
||||
# 2. Vet (catches suspicious code that compiles but is likely wrong)
|
||||
go vet ./...
|
||||
|
||||
# 3. Lint (golangci-lint aggregates 50+ linters — the de-facto standard)
|
||||
golangci-lint run --timeout=5m ./...
|
||||
|
||||
# 4. Tests with race detector
|
||||
go test -race -count=1 ./...
|
||||
|
||||
# 5. Build verification (catches import errors, missing implementations)
|
||||
go build ./...
|
||||
```
|
||||
|
||||
**One-liner pre-push gate:**
|
||||
```bash
|
||||
gofmt -l . | grep -q . && exit 1; go vet ./... && golangci-lint run --timeout=5m && go test -race -count=1 ./... && go build ./...
|
||||
```
|
||||
|
||||
### Why each check matters
|
||||
|
||||
| Check | Catches | Time |
|
||||
|-------|---------|------|
|
||||
| `gofmt` | Formatting violations (CI rejects unformatted code) | <1s |
|
||||
| `go vet` | Printf format mismatches, unreachable code, shadowed vars | <5s |
|
||||
| `golangci-lint` | 50+ static analysis checks (errcheck, staticcheck, etc.) | 10-30s |
|
||||
| `go test -race` | Race conditions (invisible without this flag) | 10-60s |
|
||||
| `go build` | Import errors, interface mismatches | <5s |
|
||||
|
||||
---
|
||||
|
||||
## golangci-lint Configuration
|
||||
|
||||
Config lives in `.golangci.yml` at the repo root. Minimum required linters:
|
||||
|
||||
```yaml
|
||||
linters:
|
||||
enable:
|
||||
- errcheck # unchecked errors are bugs
|
||||
- gosimple # code simplification
|
||||
- govet # go vet findings
|
||||
- ineffassign # useless assignments
|
||||
- staticcheck # advanced static analysis (SA*, S*, QF*)
|
||||
- unused # unused code
|
||||
- gofmt # formatting
|
||||
- goimports # import organization
|
||||
- gocritic # opinionated style checks
|
||||
- noctx # HTTP requests without context
|
||||
- bodyclose # unclosed HTTP response bodies
|
||||
- exhaustive # exhaustive switch on enums
|
||||
- wrapcheck # errors from external packages must be wrapped
|
||||
|
||||
linters-settings:
|
||||
errcheck:
|
||||
check-blank: true # blank identifier for errors is a bug
|
||||
govet:
|
||||
enable-all: true
|
||||
|
||||
issues:
|
||||
max-issues-per-linter: 0
|
||||
max-same-issues: 0
|
||||
```
|
||||
|
||||
**Never suppress with `//nolint:` without a comment explaining why it's safe.**
|
||||
|
||||
---
|
||||
|
||||
## Code Structure (Hexagonal Architecture)
|
||||
|
||||
```
|
||||
ai-compliance-sdk/
|
||||
├── cmd/
|
||||
│ └── server/main.go # thin: parse flags, wire deps, call app.Run()
|
||||
├── internal/
|
||||
│ ├── app/ # dependency wiring
|
||||
│ ├── domain/ # pure business logic, no framework deps
|
||||
│ ├── ports/ # interfaces (repositories, external services)
|
||||
│ ├── adapters/
|
||||
│ │ ├── http/ # Gin handlers (≤30 LOC per handler)
|
||||
│ │ ├── postgres/ # DB adapters implementing ports
|
||||
│ │ └── external/ # third-party API clients
|
||||
│ └── services/ # orchestration between domain + ports
|
||||
└── pkg/ # exported, reusable packages
|
||||
```
|
||||
|
||||
**Handler constraint — max 30 lines per handler:**
|
||||
```go
|
||||
func (h *RiskHandler) GetRisk(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
risk, err := h.service.Get(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
h.handleError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, risk)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
```go
|
||||
// REQUIRED: wrap errors with context
|
||||
if err != nil {
|
||||
return fmt.Errorf("get risk %s: %w", id, err)
|
||||
}
|
||||
|
||||
// REQUIRED: define sentinel errors in domain package
|
||||
var ErrNotFound = errors.New("not found")
|
||||
var ErrUnauthorized = errors.New("unauthorized")
|
||||
|
||||
// REQUIRED: check errors — never use _ for error returns
|
||||
result, err := service.Do(ctx, input)
|
||||
if err != nil {
|
||||
// handle it
|
||||
}
|
||||
```
|
||||
|
||||
**`errcheck` linter enforces this — zero tolerance for unchecked errors.**
|
||||
|
||||
---
|
||||
|
||||
## Testing Requirements
|
||||
|
||||
```
|
||||
internal/
|
||||
├── domain/
|
||||
│ ├── risk.go
|
||||
│ └── risk_test.go # unit: pure functions, no I/O
|
||||
├── adapters/
|
||||
│ ├── http/
|
||||
│ │ ├── handler.go
|
||||
│ │ └── handler_test.go # httptest-based, mock service
|
||||
│ └── postgres/
|
||||
│ ├── repo.go
|
||||
│ └── repo_test.go # integration: testcontainers or real DB
|
||||
```
|
||||
|
||||
**Test naming convention:**
|
||||
```go
|
||||
func TestRiskService_Get_ReturnsRisk(t *testing.T) {}
|
||||
func TestRiskService_Get_NotFound_ReturnsError(t *testing.T) {}
|
||||
func TestRiskService_Get_DBError_WrapsError(t *testing.T) {}
|
||||
```
|
||||
|
||||
**Table-driven tests are mandatory for functions with multiple cases:**
|
||||
```go
|
||||
func TestValidateInput(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantErr bool
|
||||
}{
|
||||
{"valid", "ok", false},
|
||||
{"empty", "", true},
|
||||
{"too long", strings.Repeat("x", 300), true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateInput(tt.input)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("got err=%v, wantErr=%v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
# Pre-push: unit tests only (fast)
|
||||
go test -race -count=1 -run "^TestUnit" ./...
|
||||
|
||||
# CI: all tests
|
||||
go test -race -count=1 -coverprofile=coverage.out ./...
|
||||
go tool cover -func=coverage.out | grep total
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Context Propagation
|
||||
|
||||
Every function that does I/O (DB, HTTP, file) **must** accept and pass `context.Context` as the first argument:
|
||||
|
||||
```go
|
||||
// REQUIRED
|
||||
func (r *RiskRepo) Get(ctx context.Context, id uuid.UUID) (*Risk, error) {
|
||||
return r.db.QueryRowContext(ctx, query, id).Scan(...)
|
||||
}
|
||||
|
||||
// FORBIDDEN — no context
|
||||
func (r *RiskRepo) Get(id uuid.UUID) (*Risk, error) { ... }
|
||||
```
|
||||
|
||||
`noctx` linter enforces HTTP client context. Manual review required for DB calls.
|
||||
|
||||
---
|
||||
|
||||
## Common Pitfalls That Break CI
|
||||
|
||||
| Pitfall | Prevention |
|
||||
|---------|------------|
|
||||
| Unformatted code | `gofmt -w .` before commit |
|
||||
| Unchecked error return from `rows.Close()` / `resp.Body.Close()` | `errcheck` + `bodyclose` linters |
|
||||
| Goroutine leak (goroutine started but never stopped) | `-race` test flag |
|
||||
| Shadowed `err` variable in nested scope | `govet -shadow` |
|
||||
| HTTP response body not closed | `bodyclose` linter |
|
||||
| `interface{}` instead of `any` (Go 1.18+) | `gocritic` |
|
||||
| Missing context on DB/HTTP calls | `noctx` linter |
|
||||
| Returning concrete type from constructor instead of interface | breaks testability |
|
||||
@@ -0,0 +1,157 @@
|
||||
# AGENTS.python.md — Python Agent Rules
|
||||
|
||||
Applies to: `backend-compliance/`, `ai-compliance-sdk/` (Python path), `compliance-tts-service/`, `document-crawler/`, `dsms-gateway/` (Python services)
|
||||
|
||||
---
|
||||
|
||||
## NON-NEGOTIABLE: Pre-Push Checklist
|
||||
|
||||
**BEFORE every `git push`, run ALL of the following from the service directory. A single failure blocks the push.**
|
||||
|
||||
```bash
|
||||
# 1. Fast lint (Ruff — catches syntax errors, unused imports, style violations)
|
||||
ruff check .
|
||||
|
||||
# 2. Auto-fix safe issues, then re-check
|
||||
ruff check --fix . && ruff check .
|
||||
|
||||
# 3. Type checking (mypy strict on new modules, standard on legacy)
|
||||
mypy . --ignore-missing-imports --no-error-summary
|
||||
|
||||
# 4. Unit tests only (fast, no external deps)
|
||||
pytest tests/unit/ -x -q --no-header
|
||||
|
||||
# 5. Verify the service starts (catches import errors, missing env vars with defaults)
|
||||
python -c "import app" 2>/dev/null || python -c "import main" 2>/dev/null || true
|
||||
```
|
||||
|
||||
**One-liner pre-push gate (run from service root):**
|
||||
```bash
|
||||
ruff check . && mypy . --ignore-missing-imports --no-error-summary && pytest tests/ -x -q --no-header
|
||||
```
|
||||
|
||||
### Why each check matters
|
||||
|
||||
| Check | Catches | Time |
|
||||
|-------|---------|------|
|
||||
| `ruff check` | Syntax errors, unused imports, undefined names | <2s |
|
||||
| `mypy` | Type mismatches, wrong argument types | 5-15s |
|
||||
| `pytest -x` | Logic errors, regressions | 10-60s |
|
||||
| import check | Missing packages, circular imports | <1s |
|
||||
|
||||
---
|
||||
|
||||
## Code Style (Ruff)
|
||||
|
||||
Config lives in `pyproject.toml`. Do **not** add per-file `# noqa` suppressions without a comment explaining why.
|
||||
|
||||
```toml
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py311"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "W", "I", "N", "UP", "B", "C4", "SIM", "TCH"]
|
||||
ignore = ["E501"] # line length handled by formatter
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"tests/*" = ["S101"] # assert is fine in tests
|
||||
```
|
||||
|
||||
**Blocked patterns:**
|
||||
- `from module import *` — always name imports explicitly
|
||||
- Bare `except:` — use `except Exception as e:` at minimum
|
||||
- `print()` in production code — use `logger`
|
||||
- Mutable default arguments: `def f(x=[])` → `def f(x=None)`
|
||||
|
||||
---
|
||||
|
||||
## Type Annotations
|
||||
|
||||
All new functions **must** have complete type annotations. Use `from __future__ import annotations` for forward references.
|
||||
|
||||
```python
|
||||
# Required
|
||||
async def get_tenant(tenant_id: str, db: AsyncSession) -> TenantModel | None:
|
||||
...
|
||||
|
||||
# Required for complex types
|
||||
from typing import Sequence
|
||||
def list_risks(filters: dict[str, str]) -> Sequence[RiskModel]:
|
||||
...
|
||||
```
|
||||
|
||||
**Mypy rules:**
|
||||
- `--disallow-untyped-defs` on new files
|
||||
- `--strict` on new modules (not legacy)
|
||||
- Never use `type: ignore` without a comment
|
||||
|
||||
---
|
||||
|
||||
## FastAPI-Specific Rules
|
||||
|
||||
```python
|
||||
# Handlers stay thin — delegate to service layer
|
||||
@router.get("/risks/{risk_id}", response_model=RiskResponse)
|
||||
async def get_risk(risk_id: UUID, service: RiskService = Depends(get_risk_service)):
|
||||
return await service.get(risk_id) # ≤5 lines per handler
|
||||
|
||||
# Always use response_model — never return raw dicts from endpoints
|
||||
# Always validate input with Pydantic — no manual dict parsing
|
||||
# Use HTTPException with specific status codes, never bare 500
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Requirements
|
||||
|
||||
```
|
||||
tests/
|
||||
├── unit/ # Pure logic tests, no DB/HTTP (run on every push)
|
||||
├── integration/ # Requires running services (run in CI only)
|
||||
└── contracts/ # OpenAPI snapshot tests (run on API changes)
|
||||
```
|
||||
|
||||
**Unit test requirements:**
|
||||
- Every new function → at least one happy-path test
|
||||
- Every bug fix → regression test that would have caught it
|
||||
- Mock all I/O: DB calls, HTTP calls, filesystem reads
|
||||
|
||||
```bash
|
||||
# Run unit tests only (fast, for pre-push)
|
||||
pytest tests/unit/ -x -q
|
||||
|
||||
# Run with coverage (for CI)
|
||||
pytest tests/ --cov=. --cov-report=term-missing --cov-fail-under=70
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Dependency Management
|
||||
|
||||
```bash
|
||||
# Check new package license before adding
|
||||
pip show <package> | grep -E "License|Home-page"
|
||||
|
||||
# After adding to requirements.txt — verify no GPL/AGPL
|
||||
pip-licenses --fail-on="GPL;AGPL" 2>/dev/null || echo "Check licenses manually"
|
||||
```
|
||||
|
||||
**Never add:**
|
||||
- GPL/AGPL licensed packages
|
||||
- Packages with known CVEs (`pip audit`)
|
||||
- Packages that only exist for dev (`pytest`, `ruff`) to production requirements
|
||||
|
||||
---
|
||||
|
||||
## Common Pitfalls That Break CI
|
||||
|
||||
| Pitfall | Prevention |
|
||||
|---------|------------|
|
||||
| `const x = ...` inside dict literal (wrong language!) | Run ruff before push |
|
||||
| Pydantic v1 syntax in v2 project | Use `model_config`, not `class Config` |
|
||||
| Sync function called inside async without `run_in_executor` | mypy + async linter |
|
||||
| Missing `await` on coroutine | mypy catches this |
|
||||
| `datetime.utcnow()` (deprecated) | Use `datetime.now(timezone.utc)` |
|
||||
| Bare `except:` swallowing errors silently | ruff B001/E722 catches this |
|
||||
| Unused imports left in committed code | ruff F401 catches this |
|
||||
@@ -0,0 +1,186 @@
|
||||
# AGENTS.typescript.md — TypeScript/Next.js Agent Rules
|
||||
|
||||
Applies to: `pitch-deck/`, `admin-v2/` (Next.js apps in this repo)
|
||||
|
||||
---
|
||||
|
||||
## NON-NEGOTIABLE: Pre-Push Checklist
|
||||
|
||||
**BEFORE every `git push`, run ALL of the following from the Next.js app directory. A single failure blocks the push.**
|
||||
|
||||
```bash
|
||||
# 1. Type check (catches the class of bug that broke ChatFAB.tsx — const inside object)
|
||||
npx tsc --noEmit
|
||||
|
||||
# 2. Lint (ESLint with TypeScript-aware rules)
|
||||
npm run lint
|
||||
|
||||
# 3. Production build (THE most important check — passes lint/types but still fails build)
|
||||
npm run build
|
||||
```
|
||||
|
||||
**One-liner pre-push gate:**
|
||||
```bash
|
||||
npx tsc --noEmit && npm run lint && npm run build
|
||||
```
|
||||
|
||||
> **Why `npm run build` is mandatory:** Next.js performs additional checks during build (server component boundaries, missing env vars referenced in code, RSC/client component violations) that `tsc` and ESLint alone do not catch. The ChatFAB syntax error (`const` inside object literal) is exactly the kind of error caught only by build.
|
||||
|
||||
### Why each check matters
|
||||
|
||||
| Check | Catches | Time |
|
||||
|-------|---------|------|
|
||||
| `tsc --noEmit` | Type errors, wrong prop types, missing members | 5-20s |
|
||||
| `eslint` | React hooks rules, import order, unused vars | 5-15s |
|
||||
| `next build` | Server/client boundary violations, missing deps, syntax errors in JSX, env var issues | 30-120s |
|
||||
|
||||
---
|
||||
|
||||
## TypeScript Configuration
|
||||
|
||||
`tsconfig.json` must have strict mode enabled:
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"noImplicitAny": true,
|
||||
"strictNullChecks": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Never use `// @ts-ignore` or `// @ts-expect-error` without a comment explaining why it's unavoidable.**
|
||||
|
||||
---
|
||||
|
||||
## ESLint Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"extends": [
|
||||
"next/core-web-vitals",
|
||||
"plugin:@typescript-eslint/recommended-type-checked"
|
||||
],
|
||||
"rules": {
|
||||
"@typescript-eslint/no-explicit-any": "error",
|
||||
"@typescript-eslint/no-unused-vars": "error",
|
||||
"@typescript-eslint/no-floating-promises": "error",
|
||||
"@typescript-eslint/await-thenable": "error",
|
||||
"react-hooks/exhaustive-deps": "error",
|
||||
"no-console": "warn"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**`@typescript-eslint/no-floating-promises`** — catches `await`-less async calls that silently swallow errors.
|
||||
**`react-hooks/exhaustive-deps`** — catches missing deps in `useEffect`/`useCallback` (source of stale closure bugs).
|
||||
|
||||
---
|
||||
|
||||
## Next.js 15 Rules (App Router)
|
||||
|
||||
### Server vs Client boundary
|
||||
|
||||
```typescript
|
||||
// Server Component (default) — no 'use client' needed
|
||||
// Can: fetch data, access DB, read env vars, import server-only packages
|
||||
async function Page() {
|
||||
const data = await fetchData() // direct async/await
|
||||
return <ClientComponent data={data} />
|
||||
}
|
||||
|
||||
// Client Component — must have 'use client' at top
|
||||
'use client'
|
||||
// Can: use hooks, handle events, access browser APIs
|
||||
// Cannot: import server-only packages (nodemailer, fs, db pool)
|
||||
```
|
||||
|
||||
**Common violation:** Importing `lib/email.ts` (which imports nodemailer) from a client component → use `lib/email-templates.ts` instead.
|
||||
|
||||
### Route Handler typing
|
||||
|
||||
```typescript
|
||||
// Always type request and use NextResponse
|
||||
export async function GET(request: Request): Promise<NextResponse> {
|
||||
const { searchParams } = new URL(request.url)
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
```
|
||||
|
||||
### Environment variables
|
||||
|
||||
```typescript
|
||||
// Server-only env vars: access directly
|
||||
const secret = process.env.PITCH_ADMIN_SECRET // fine in server components
|
||||
|
||||
// Client env vars: must be prefixed NEXT_PUBLIC_
|
||||
const url = process.env.NEXT_PUBLIC_API_URL // accessible in browser
|
||||
|
||||
// Never access server-only env vars in 'use client' components
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Component Architecture
|
||||
|
||||
```
|
||||
app/
|
||||
├── (route-group)/
|
||||
│ ├── page.tsx # Server Component — data fetching
|
||||
│ └── _components/ # Colocated components for this route
|
||||
│ ├── ClientThing.tsx # 'use client' when needed
|
||||
│ └── ServerThing.tsx # Server by default
|
||||
components/
|
||||
│ └── ui/ # Shared presentational components
|
||||
lib/
|
||||
│ ├── server-only-module.ts # import 'server-only' at top
|
||||
│ └── shared-module.ts # safe for both server and client
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
- Push `'use client'` boundary as deep as possible (toward leaves)
|
||||
- Never import server-only modules from client components
|
||||
- Colocate `_components/` and `_hooks/` per route when they're route-specific
|
||||
|
||||
---
|
||||
|
||||
## Testing Requirements
|
||||
|
||||
```bash
|
||||
# Type check (fastest, run first)
|
||||
npx tsc --noEmit
|
||||
|
||||
# Unit tests (Vitest)
|
||||
npx vitest run
|
||||
|
||||
# E2E tests (Playwright — CI only, requires running server)
|
||||
npx playwright test
|
||||
```
|
||||
|
||||
**Test every:**
|
||||
- Custom hook (`usePresenterMode`, `useSlideNavigation`)
|
||||
- Utility function (`lib/auth.ts` helpers, `lib/email-templates.ts`)
|
||||
- API route handler (mock DB, assert response shape)
|
||||
|
||||
---
|
||||
|
||||
## Common Pitfalls That Break CI
|
||||
|
||||
| Pitfall | Prevention |
|
||||
|---------|------------|
|
||||
| `const x = ...` inside object literal | `tsc --noEmit` + `npm run build` |
|
||||
| Server-only import in client component | `import 'server-only'` guard + ESLint |
|
||||
| Missing `await` on async function call | `@typescript-eslint/no-floating-promises` |
|
||||
| `useEffect` with missing dependency | `react-hooks/exhaustive-deps` error |
|
||||
| `any` type hiding type errors | `@typescript-eslint/no-explicit-any` error |
|
||||
| Unused variable left after refactor | `noUnusedLocals` in tsconfig |
|
||||
| `process.env.SECRET` in client component | Next.js build error |
|
||||
| Forgetting `export default` on page component | Next.js build error |
|
||||
| Calling server action from server component | must use route handler instead |
|
||||
| `jose` full import in Edge Runtime | Use specific subpath: `jose/jwt/verify` |
|
||||
+46
-15
@@ -2,29 +2,29 @@
|
||||
|
||||
## Entwicklungsumgebung (WICHTIG - IMMER ZUERST LESEN)
|
||||
|
||||
### Zwei-Rechner-Setup + Coolify
|
||||
### Zwei-Rechner-Setup + Orca
|
||||
|
||||
| Geraet | Rolle | Aufgaben |
|
||||
|--------|-------|----------|
|
||||
| **MacBook** | Entwicklung | Claude Terminal, Code-Entwicklung, Browser (Frontend-Tests) |
|
||||
| **Mac Mini** | Lokaler Server | Docker fuer lokale Dev/Tests (NICHT fuer Production!) |
|
||||
| **Coolify** | Production | Automatisches Build + Deploy bei Push auf gitea |
|
||||
| **Orca** | Production | Automatisches Build + Deploy bei Push auf gitea |
|
||||
|
||||
**WICHTIG:** Code wird direkt auf dem MacBook in diesem Repo bearbeitet. Production-Deployment laeuft automatisch ueber Coolify.
|
||||
**WICHTIG:** Code wird direkt auf dem MacBook in diesem Repo bearbeitet. Production-Deployment laeuft automatisch ueber Orca.
|
||||
|
||||
### Entwicklungsworkflow (CI/CD — Coolify)
|
||||
### Entwicklungsworkflow (CI/CD — Orca)
|
||||
|
||||
```bash
|
||||
# 1. Code auf MacBook bearbeiten (dieses Verzeichnis)
|
||||
# 2. Committen und zu BEIDEN Remotes pushen:
|
||||
git push origin main && git push gitea main
|
||||
git push origin main
|
||||
|
||||
# 3. FERTIG! Push auf gitea triggert automatisch:
|
||||
# - Gitea Actions: Tests
|
||||
# - Coolify: Build → Deploy
|
||||
# - Orca: Build → Deploy
|
||||
```
|
||||
|
||||
**NIEMALS** manuell in Coolify auf "Redeploy" klicken — Gitea Actions triggert Coolify automatisch.
|
||||
**NIEMALS** manuell in Orca auf "Redeploy" klicken — Gitea Actions triggert Orca automatisch.
|
||||
**IMMER auf `main` pushen** — sowohl origin als auch gitea.
|
||||
|
||||
### Post-Push Deploy-Monitoring (PFLICHT nach jedem Push auf gitea)
|
||||
@@ -39,7 +39,7 @@ git push origin main && git push gitea main
|
||||
```
|
||||
3. Sobald ALLE Endpoints healthy sind, dem User im Chat melden:
|
||||
**"Deploy abgeschlossen! Du kannst jetzt testen."**
|
||||
4. Falls nach 5 Minuten noch nicht healthy → Fehlermeldung mit Hinweis auf Coolify-Logs.
|
||||
4. Falls nach 5 Minuten noch nicht healthy → Fehlermeldung mit Hinweis auf Orca-Logs.
|
||||
|
||||
### Lokale Entwicklung (Mac Mini — optional, nur Dev/Tests)
|
||||
|
||||
@@ -80,8 +80,8 @@ networks:
|
||||
|
||||
| Repo | Deployment | Trigger |
|
||||
|------|-----------|---------|
|
||||
| **breakpilot-core** | Coolify (automatisch) | Push auf gitea main |
|
||||
| **breakpilot-compliance** | Coolify (automatisch) | Push auf gitea main |
|
||||
| **breakpilot-core** | Orca (automatisch) | Push auf gitea main |
|
||||
| **breakpilot-compliance** | Orca (automatisch) | Push auf gitea main |
|
||||
| **breakpilot-lehrer** | Mac Mini (lokal) | Manuell docker compose |
|
||||
|
||||
---
|
||||
@@ -252,8 +252,8 @@ ssh macmini "/usr/local/bin/docker logs -f bp-core-control-pipeline"
|
||||
### Deployment (CI/CD — Standardweg)
|
||||
|
||||
```bash
|
||||
# Committen und pushen → Coolify deployt automatisch:
|
||||
git push origin main && git push gitea main
|
||||
# Committen und pushen → Orca deployt automatisch:
|
||||
git push origin main
|
||||
```
|
||||
|
||||
### Lokale Docker-Befehle (Mac Mini — nur Dev/Tests)
|
||||
@@ -278,15 +278,46 @@ ssh macmini "/usr/local/bin/docker ps --filter name=bp-core"
|
||||
|
||||
```bash
|
||||
# Zu BEIDEN Remotes pushen (PFLICHT!):
|
||||
git push origin main && git push gitea main
|
||||
git push origin main
|
||||
|
||||
# Remotes:
|
||||
# origin: lokale Gitea (macmini:3003)
|
||||
# gitea: gitea.meghsakha.com
|
||||
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pre-Push Checks (PFLICHT — VOR JEDEM PUSH)
|
||||
|
||||
> Full detail: `.claude/rules/pre-push-checks.md` | Stack rules: `AGENTS.python.md`, `AGENTS.go.md`, `AGENTS.typescript.md`
|
||||
|
||||
**NIEMALS pushen ohne diese Checks. CI-Failures blockieren das gesamte Deploy.**
|
||||
|
||||
### Python (backend-core, rag-service, embedding-service, control-pipeline)
|
||||
|
||||
```bash
|
||||
cd <service-dir>
|
||||
ruff check . && mypy . --ignore-missing-imports --no-error-summary && pytest tests/ -x -q --no-header
|
||||
```
|
||||
|
||||
### Go (consent-service, billing-service)
|
||||
|
||||
```bash
|
||||
cd <service-dir>
|
||||
gofmt -l . | grep -q . && exit 1; go vet ./... && golangci-lint run --timeout=5m && go test -race ./... && go build ./...
|
||||
```
|
||||
|
||||
### TypeScript/Next.js (pitch-deck, admin-v2)
|
||||
|
||||
```bash
|
||||
cd pitch-deck # or admin-v2
|
||||
npx tsc --noEmit && npm run lint && npm run build
|
||||
```
|
||||
|
||||
> `npm run build` ist PFLICHT — `tsc` allein reicht nicht. Syntax-Fehler wie `const` inside object literal werden nur vom Build gefangen.
|
||||
|
||||
---
|
||||
|
||||
## Kernprinzipien
|
||||
|
||||
### 1. Open Source Policy
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
# Pre-Push Checks (MANDATORY)
|
||||
|
||||
## Rule
|
||||
|
||||
**NEVER push to any remote without first running and confirming ALL checks pass for every changed language stack.**
|
||||
|
||||
This rule exists because CI failures break the deploy pipeline for everyone and waste ~5 minutes per failed build. A 60-second local check prevents that.
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference by Stack
|
||||
|
||||
### Python (backend-compliance, ai-compliance-sdk, compliance-tts-service)
|
||||
|
||||
```bash
|
||||
cd <service-dir>
|
||||
ruff check . && mypy . --ignore-missing-imports --no-error-summary && pytest tests/ -x -q --no-header
|
||||
```
|
||||
|
||||
Blocks on: syntax errors, type errors, failing tests.
|
||||
|
||||
### Go (ai-compliance-sdk Go path)
|
||||
|
||||
```bash
|
||||
cd <service-dir>
|
||||
gofmt -l . | grep -q . && exit 1; go vet ./... && golangci-lint run --timeout=5m && go test -race ./... && go build ./...
|
||||
```
|
||||
|
||||
Blocks on: formatting, vet findings, lint violations, test failures, build errors.
|
||||
|
||||
### TypeScript/Next.js (admin-compliance, developer-portal)
|
||||
|
||||
```bash
|
||||
cd <nextjs-app-dir>
|
||||
npx tsc --noEmit && npm run lint && npm run build
|
||||
```
|
||||
|
||||
Blocks on: type errors, lint violations, **build failures**.
|
||||
|
||||
> `npm run build` is mandatory — `tsc` passes but `next build` fails more often than you'd expect (server/client boundary violations, env var issues, JSX syntax errors).
|
||||
|
||||
---
|
||||
|
||||
## What Claude Must Do Before Every Push
|
||||
|
||||
1. Identify which services/apps were changed in this task
|
||||
2. Run the appropriate gate command(s) from the table above
|
||||
3. If any check fails: fix it, re-run, confirm green
|
||||
4. Only then run `git push origin main`
|
||||
|
||||
**No exceptions.** A push that skips pre-push checks and breaks CI is worse than a delayed push.
|
||||
|
||||
---
|
||||
|
||||
## CI vs Local Checks
|
||||
|
||||
| Stage | Where | What |
|
||||
|-------|-------|------|
|
||||
| Pre-push (local) | Claude runs | Lint + type check + unit tests + build |
|
||||
| CI (Gitea Actions) | Automatic on push | Same + integration tests + contract tests |
|
||||
| Deploy (Orca) | Automatic after CI | Docker build + health check |
|
||||
|
||||
Local checks catch 90% of CI failures in seconds. CI is the safety net, not the first line of defense.
|
||||
|
||||
---
|
||||
|
||||
## Failures That Were Caused by Skipping Pre-Push Checks
|
||||
|
||||
- `ChatFAB.tsx`: `const textLang` inside fetch object literal — caught by `tsc --noEmit` and `npm run build`
|
||||
- `nodemailer` webpack error: server-only import in client component — caught by `npm run build`
|
||||
- `jose` Edge Runtime error: full package import — caught by `npm run build`
|
||||
- `main.py` `<en>` tags spoken: missing `import re` — caught by `python -c "import main"`
|
||||
|
||||
These all caused a broken deploy. Each would have been caught in <60 seconds locally.
|
||||
@@ -0,0 +1,66 @@
|
||||
# Build + push pitch-deck Docker image to registry.meghsakha.com
|
||||
# and trigger orca redeploy on every push to main that touches pitch-deck/.
|
||||
#
|
||||
# Requires Gitea Actions secret: ORCA_WEBHOOK_SECRET
|
||||
# (must match the `secret` field in ~/.orca/webhooks.json on the orca master)
|
||||
|
||||
name: Build pitch-deck
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'pitch-deck/**'
|
||||
|
||||
jobs:
|
||||
build-push-deploy:
|
||||
runs-on: docker
|
||||
container:
|
||||
image: docker:27-cli
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
apk add --no-cache git openssl curl
|
||||
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||
|
||||
- name: Login to registry
|
||||
env:
|
||||
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
||||
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
run: |
|
||||
echo "$REGISTRY_PASSWORD" | docker login registry.meghsakha.com -u "$REGISTRY_USERNAME" --password-stdin
|
||||
|
||||
- name: Build image
|
||||
run: |
|
||||
cd pitch-deck
|
||||
SHORT_SHA=$(git rev-parse --short HEAD)
|
||||
docker build \
|
||||
--build-arg GIT_SHA=${SHORT_SHA} \
|
||||
-t registry.meghsakha.com/breakpilot/pitch-deck:latest \
|
||||
-t registry.meghsakha.com/breakpilot/pitch-deck:${SHORT_SHA} \
|
||||
.
|
||||
|
||||
- name: Push to registry
|
||||
run: |
|
||||
SHORT_SHA=$(git rev-parse --short HEAD)
|
||||
docker push registry.meghsakha.com/breakpilot/pitch-deck:latest
|
||||
docker push registry.meghsakha.com/breakpilot/pitch-deck:${SHORT_SHA}
|
||||
echo "Pushed :latest + :${SHORT_SHA}"
|
||||
|
||||
- name: Trigger orca redeploy
|
||||
env:
|
||||
ORCA_WEBHOOK_SECRET: ${{ secrets.ORCA_WEBHOOK_SECRET }}
|
||||
ORCA_WEBHOOK_URL: http://46.225.100.82:6880/api/v1/webhooks/github
|
||||
run: |
|
||||
SHA=$(git rev-parse HEAD)
|
||||
PAYLOAD="{\"ref\":\"refs/heads/main\",\"repository\":{\"full_name\":\"${GITHUB_REPOSITORY}\"},\"head_commit\":{\"id\":\"$SHA\",\"message\":\"ci: pitch-deck image build\"}}"
|
||||
SIG=$(printf '%s' "$PAYLOAD" | openssl dgst -sha256 -hmac "$ORCA_WEBHOOK_SECRET" -r | awk '{print $1}')
|
||||
curl -sSf -k \
|
||||
-X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-GitHub-Event: push" \
|
||||
-H "X-Hub-Signature-256: sha256=$SIG" \
|
||||
-d "$PAYLOAD" \
|
||||
"$ORCA_WEBHOOK_URL" \
|
||||
|| { echo "Orca redeploy failed"; exit 1; }
|
||||
echo "Orca redeploy triggered"
|
||||
@@ -140,20 +140,6 @@ jobs:
|
||||
python -m pytest tests/bqas/ -v --tb=short || true
|
||||
|
||||
# ========================================
|
||||
# Deploy via Coolify (nur main, kein PR)
|
||||
# Deploys now handled by per-service workflows (e.g. build-pitch-deck.yml)
|
||||
# which trigger orca webhooks directly after building + pushing the image.
|
||||
# ========================================
|
||||
|
||||
deploy-coolify:
|
||||
name: Deploy
|
||||
runs-on: docker
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
needs:
|
||||
- test-go-consent
|
||||
container:
|
||||
image: alpine:latest
|
||||
steps:
|
||||
- name: Trigger Coolify deploy
|
||||
run: |
|
||||
apk add --no-cache curl
|
||||
curl -sf "${{ secrets.COOLIFY_WEBHOOK }}" \
|
||||
-H "Authorization: Bearer ${{ secrets.COOLIFY_TOKEN }}"
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
name: Deploy to Coolify
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- coolify
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Deploy via Coolify API
|
||||
run: |
|
||||
echo "Deploying breakpilot-core to Coolify..."
|
||||
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
-X POST \
|
||||
-H "Authorization: Bearer ${{ secrets.COOLIFY_API_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"uuid": "${{ secrets.COOLIFY_RESOURCE_UUID }}", "force_rebuild": true}' \
|
||||
"${{ secrets.COOLIFY_BASE_URL }}/api/v1/deploy")
|
||||
|
||||
echo "HTTP Status: $HTTP_STATUS"
|
||||
if [ "$HTTP_STATUS" -ne 200 ] && [ "$HTTP_STATUS" -ne 201 ]; then
|
||||
echo "Deployment failed with status $HTTP_STATUS"
|
||||
exit 1
|
||||
fi
|
||||
echo "Deployment triggered successfully!"
|
||||
@@ -7,6 +7,7 @@
|
||||
secrets/
|
||||
*.pem
|
||||
*.key
|
||||
.mcp.json
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
|
||||
@@ -37,6 +37,8 @@ from services.control_generator import (
|
||||
)
|
||||
from services.citation_backfill import CitationBackfill, BackfillResult
|
||||
from services.rag_client import get_rag_client
|
||||
from services.anchor_finder import AnchorFinder, OpenAnchor
|
||||
from services.control_generator import GeneratedControl as _GeneratedControl
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/v1/canonical", tags=["control-generator"])
|
||||
@@ -52,7 +54,7 @@ class GenerateRequest(BaseModel):
|
||||
max_controls: int = 50
|
||||
max_chunks: int = 1000 # Default: process max 1000 chunks per job (respects document boundaries)
|
||||
batch_size: int = 5
|
||||
skip_web_search: bool = False
|
||||
skip_web_search: bool = True # Default True — Anchors nachtraeglich batchen
|
||||
dry_run: bool = False
|
||||
regulation_filter: Optional[List[str]] = None # Only process these regulation_code prefixes
|
||||
regulation_exclude: Optional[List[str]] = None # Skip these regulation_code prefixes
|
||||
@@ -1100,3 +1102,398 @@ async def get_source_type_backfill_status(backfill_id: str):
|
||||
if not status:
|
||||
raise HTTPException(status_code=404, detail="Source-type backfill job not found")
|
||||
return status
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# REPAIR BACKFILL — Fix controls with missing title/objective/requirements
|
||||
# =============================================================================
|
||||
|
||||
class RepairBackfillRequest(BaseModel):
|
||||
dry_run: bool = True
|
||||
limit: int = 0 # 0 = all
|
||||
batch_size: int = 10
|
||||
|
||||
|
||||
_repair_backfill_status: dict = {}
|
||||
|
||||
|
||||
async def _run_repair_backfill(req: RepairBackfillRequest, backfill_id: str):
|
||||
"""Repair controls with missing title, objective, or requirements using Anthropic API."""
|
||||
import os
|
||||
import httpx
|
||||
|
||||
ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY", "")
|
||||
ANTHROPIC_MODEL = os.getenv("CONTROL_GEN_ANTHROPIC_MODEL", "claude-sonnet-4-6")
|
||||
|
||||
if not ANTHROPIC_API_KEY:
|
||||
_repair_backfill_status[backfill_id] = {
|
||||
"status": "failed", "error": "ANTHROPIC_API_KEY not set"
|
||||
}
|
||||
return
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# Find controls needing repair: missing title OR missing objective OR missing requirements
|
||||
limit_clause = f"LIMIT {req.limit}" if req.limit > 0 else ""
|
||||
rows = db.execute(text(f"""
|
||||
SELECT id, control_id, title, objective, requirements::text as requirements,
|
||||
source_original_text, tags::text as tags, category
|
||||
FROM compliance.canonical_controls
|
||||
WHERE release_state = 'draft'
|
||||
AND (
|
||||
(title IS NULL OR title = 'None' OR title = '')
|
||||
OR (objective IS NULL OR objective = 'None' OR objective = '')
|
||||
OR (requirements IS NULL OR requirements::text = '[]' OR requirements::text = 'null')
|
||||
)
|
||||
ORDER BY control_id
|
||||
{limit_clause}
|
||||
""")).fetchall()
|
||||
|
||||
total = len(rows)
|
||||
repaired = 0
|
||||
skipped = 0
|
||||
errors = []
|
||||
|
||||
_repair_backfill_status[backfill_id] = {
|
||||
"status": "running", "total": total, "repaired": 0, "skipped": 0,
|
||||
"dry_run": req.dry_run, "errors": [],
|
||||
}
|
||||
|
||||
for i in range(0, total, req.batch_size):
|
||||
batch = rows[i:i + req.batch_size]
|
||||
|
||||
entries = []
|
||||
for idx, row in enumerate(batch):
|
||||
# Collect all available context
|
||||
available = []
|
||||
if row.title and row.title != "None":
|
||||
available.append(f"Titel: {row.title}")
|
||||
if row.objective and row.objective != "None":
|
||||
available.append(f"Objective: {row.objective[:500]}")
|
||||
if row.requirements and row.requirements not in ("[]", "null", "None"):
|
||||
available.append(f"Requirements: {row.requirements[:500]}")
|
||||
if row.source_original_text and len(row.source_original_text) > 20:
|
||||
available.append(f"Quelltext: {row.source_original_text[:800]}")
|
||||
if row.category:
|
||||
available.append(f"Kategorie: {row.category}")
|
||||
|
||||
missing = []
|
||||
if not row.title or row.title == "None":
|
||||
missing.append("title")
|
||||
if not row.objective or row.objective == "None":
|
||||
missing.append("objective")
|
||||
if not row.requirements or row.requirements in ("[]", "null", "None"):
|
||||
missing.append("requirements")
|
||||
|
||||
entries.append(
|
||||
f"--- CONTROL {idx + 1}: {row.control_id} ---\n"
|
||||
f"Fehlend: {', '.join(missing)}\n"
|
||||
f"{'chr(10)'.join(available)}\n"
|
||||
)
|
||||
|
||||
prompt = f"""Repariere die folgenden {len(batch)} Compliance-Controls. Fuer jedes Control fehlen bestimmte Felder.
|
||||
|
||||
Regeln:
|
||||
- title: Kurzer, praegnanter deutscher Titel (max 80 Zeichen). Erster Buchstabe gross.
|
||||
- objective: 1-2 Saetze die das Ziel des Controls beschreiben.
|
||||
- requirements: JSON-Array mit 2-5 konkreten Anforderungen als Strings.
|
||||
- Nur die fehlenden Felder generieren. Bestehende Felder NICHT aendern.
|
||||
- Wenn nicht genug Kontext vorhanden ist, schreibe "SKIP" als Wert.
|
||||
|
||||
Antworte mit einem JSON-Array. Jedes Objekt hat:
|
||||
- control_index: 1-basierter Index
|
||||
- title: (nur wenn fehlend, sonst null)
|
||||
- objective: (nur wenn fehlend, sonst null)
|
||||
- requirements: (nur wenn fehlend, sonst null — als JSON-Array von Strings)
|
||||
|
||||
{chr(10).join(entries)}"""
|
||||
|
||||
try:
|
||||
headers = {
|
||||
"x-api-key": ANTHROPIC_API_KEY,
|
||||
"anthropic-version": "2023-06-01",
|
||||
"content-type": "application/json",
|
||||
}
|
||||
payload = {
|
||||
"model": ANTHROPIC_MODEL,
|
||||
"max_tokens": 4096,
|
||||
"system": "Du bist ein Compliance-Experte. Repariere unvollstaendige Controls. Antworte NUR mit validem JSON.",
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||
resp = await client.post(
|
||||
"https://api.anthropic.com/v1/messages",
|
||||
headers=headers,
|
||||
json=payload,
|
||||
)
|
||||
|
||||
if resp.status_code != 200:
|
||||
errors.append(f"Batch {i}: API {resp.status_code}")
|
||||
continue
|
||||
|
||||
content = resp.json().get("content", [{}])[0].get("text", "")
|
||||
parsed = _parse_llm_json(content)
|
||||
if not parsed:
|
||||
errors.append(f"Batch {i}: JSON parse failed")
|
||||
continue
|
||||
|
||||
# Handle single object response
|
||||
items = parsed if isinstance(parsed, list) else [parsed]
|
||||
|
||||
for item in items:
|
||||
idx = item.get("control_index", 0) - 1
|
||||
if idx < 0 or idx >= len(batch):
|
||||
continue
|
||||
|
||||
row = batch[idx]
|
||||
updates = []
|
||||
params = {"cid": str(row.id)}
|
||||
|
||||
new_title = item.get("title")
|
||||
if new_title and new_title != "SKIP" and (not row.title or row.title == "None"):
|
||||
updates.append("title = :title")
|
||||
params["title"] = new_title
|
||||
|
||||
new_obj = item.get("objective")
|
||||
if new_obj and new_obj != "SKIP" and (not row.objective or row.objective == "None"):
|
||||
updates.append("objective = :objective")
|
||||
params["objective"] = new_obj
|
||||
|
||||
new_req = item.get("requirements")
|
||||
if new_req and new_req != "SKIP" and (not row.requirements or row.requirements in ("[]", "null", "None")):
|
||||
if isinstance(new_req, list):
|
||||
updates.append("requirements = CAST(:requirements AS jsonb)")
|
||||
params["requirements"] = json.dumps(new_req)
|
||||
|
||||
if not updates:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
if not req.dry_run:
|
||||
updates.append("updated_at = NOW()")
|
||||
db.execute(text(f"""
|
||||
UPDATE compliance.canonical_controls
|
||||
SET {', '.join(updates)}
|
||||
WHERE id = CAST(:cid AS uuid)
|
||||
"""), params)
|
||||
|
||||
repaired += 1
|
||||
|
||||
if not req.dry_run:
|
||||
db.commit()
|
||||
|
||||
except Exception as e:
|
||||
errors.append(f"Batch {i}: {str(e)[:200]}")
|
||||
logger.warning("Repair backfill batch %d error: %s", i, e)
|
||||
db.rollback()
|
||||
|
||||
_repair_backfill_status[backfill_id] = {
|
||||
"status": "running", "total": total, "repaired": repaired, "skipped": skipped,
|
||||
"progress": f"{min(i + req.batch_size, total)}/{total}",
|
||||
"dry_run": req.dry_run, "errors": errors[-10:],
|
||||
}
|
||||
|
||||
_repair_backfill_status[backfill_id] = {
|
||||
"status": "completed", "total": total, "repaired": repaired, "skipped": skipped,
|
||||
"dry_run": req.dry_run, "errors": errors[-50:],
|
||||
}
|
||||
logger.info("Repair backfill %s completed: %d/%d repaired, %d skipped",
|
||||
backfill_id, repaired, total, skipped)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Repair backfill %s failed: %s", backfill_id, e)
|
||||
_repair_backfill_status[backfill_id] = {"status": "failed", "error": str(e)}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/generate/backfill-repair")
|
||||
async def start_repair_backfill(req: RepairBackfillRequest):
|
||||
"""Repair controls with missing title, objective, or requirements using Anthropic API.
|
||||
|
||||
Finds draft controls where title/objective/requirements are missing or empty,
|
||||
and generates the missing fields from available context (source text, other fields).
|
||||
Default is dry_run=True (preview only, no DB changes).
|
||||
"""
|
||||
import uuid
|
||||
backfill_id = str(uuid.uuid4())[:8]
|
||||
_repair_backfill_status[backfill_id] = {"status": "starting"}
|
||||
asyncio.create_task(_run_repair_backfill(req, backfill_id))
|
||||
return {
|
||||
"status": "running",
|
||||
"backfill_id": backfill_id,
|
||||
"message": f"Repair backfill started. Poll /generate/repair-backfill-status/{backfill_id}",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/generate/repair-backfill-status/{backfill_id}")
|
||||
async def get_repair_backfill_status(backfill_id: str):
|
||||
"""Get status of a repair backfill job."""
|
||||
status = _repair_backfill_status.get(backfill_id)
|
||||
if not status:
|
||||
raise HTTPException(status_code=404, detail="Repair backfill job not found")
|
||||
return status
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ANCHOR BACKFILL
|
||||
# =============================================================================
|
||||
|
||||
class AnchorBackfillRequest(BaseModel):
|
||||
dry_run: bool = True
|
||||
limit: int = 0 # 0 = all controls without anchors
|
||||
batch_size: int = 50
|
||||
skip_web: bool = True # Stage A only (RAG), no DuckDuckGo
|
||||
include_needs_review: bool = True # Also backfill needs_review controls
|
||||
|
||||
|
||||
_anchor_backfill_status: dict = {}
|
||||
|
||||
|
||||
async def _run_anchor_backfill(req: AnchorBackfillRequest, backfill_id: str):
|
||||
"""Backfill open_anchors for controls that were generated with skip_web_search=true."""
|
||||
from dataclasses import asdict
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
finder = AnchorFinder()
|
||||
|
||||
# Find controls without anchors
|
||||
states = "('draft', 'needs_review')" if req.include_needs_review else "('draft',)"
|
||||
limit_clause = f"LIMIT {req.limit}" if req.limit > 0 else ""
|
||||
rows = db.execute(text(f"""
|
||||
SELECT id, control_id, title, tags
|
||||
FROM compliance.canonical_controls
|
||||
WHERE release_state IN {states}
|
||||
AND (open_anchors IS NULL OR open_anchors::text = '[]'
|
||||
OR open_anchors::text = 'null' OR open_anchors::text = '')
|
||||
ORDER BY control_id
|
||||
{limit_clause}
|
||||
""")).fetchall()
|
||||
|
||||
total = len(rows)
|
||||
updated = 0
|
||||
with_anchors = 0
|
||||
empty_anchors = 0
|
||||
errors = []
|
||||
|
||||
_anchor_backfill_status[backfill_id] = {
|
||||
"status": "running", "total": total, "updated": 0,
|
||||
"with_anchors": 0, "empty_anchors": 0, "dry_run": req.dry_run,
|
||||
}
|
||||
|
||||
for i in range(0, total, req.batch_size):
|
||||
batch = rows[i:i + req.batch_size]
|
||||
|
||||
for row in batch:
|
||||
try:
|
||||
# Parse tags from DB (may be JSON string or list)
|
||||
tags = row.tags
|
||||
if isinstance(tags, str):
|
||||
try:
|
||||
tags = json.loads(tags)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
tags = []
|
||||
if not isinstance(tags, list):
|
||||
tags = []
|
||||
|
||||
# Build minimal GeneratedControl for AnchorFinder
|
||||
control = _GeneratedControl(
|
||||
title=row.title or "",
|
||||
tags=tags,
|
||||
)
|
||||
|
||||
# Find anchors (Stage A: RAG only)
|
||||
anchors = await finder.find_anchors(
|
||||
control, skip_web=req.skip_web, min_anchors=2
|
||||
)
|
||||
|
||||
anchor_dicts = [asdict(a) for a in anchors]
|
||||
|
||||
if not req.dry_run:
|
||||
db.execute(text("""
|
||||
UPDATE compliance.canonical_controls
|
||||
SET open_anchors = CAST(:anchors AS jsonb),
|
||||
updated_at = NOW()
|
||||
WHERE id = CAST(:cid AS uuid)
|
||||
"""), {"anchors": json.dumps(anchor_dicts), "cid": str(row.id)})
|
||||
|
||||
updated += 1
|
||||
if anchor_dicts:
|
||||
with_anchors += 1
|
||||
else:
|
||||
empty_anchors += 1
|
||||
|
||||
except Exception as e:
|
||||
errors.append(f"{row.control_id}: {str(e)[:200]}")
|
||||
logger.warning("Anchor backfill error for %s: %s", row.control_id, e)
|
||||
|
||||
if not req.dry_run:
|
||||
db.commit()
|
||||
|
||||
_anchor_backfill_status[backfill_id] = {
|
||||
"status": "running", "total": total, "updated": updated,
|
||||
"with_anchors": with_anchors, "empty_anchors": empty_anchors,
|
||||
"progress": f"{min(i + req.batch_size, total)}/{total}",
|
||||
"dry_run": req.dry_run, "errors": errors[-10:],
|
||||
}
|
||||
|
||||
# Promote needs_review controls that gained anchors to draft
|
||||
promoted = 0
|
||||
if not req.dry_run and req.include_needs_review:
|
||||
result = db.execute(text("""
|
||||
UPDATE compliance.canonical_controls
|
||||
SET release_state = 'draft', updated_at = NOW()
|
||||
WHERE release_state = 'needs_review'
|
||||
AND open_anchors IS NOT NULL
|
||||
AND open_anchors::text != '[]'
|
||||
AND open_anchors::text != 'null'
|
||||
AND open_anchors::text != ''
|
||||
RETURNING id
|
||||
"""))
|
||||
promoted = result.rowcount
|
||||
db.commit()
|
||||
|
||||
_anchor_backfill_status[backfill_id] = {
|
||||
"status": "completed", "total": total, "updated": updated,
|
||||
"with_anchors": with_anchors, "empty_anchors": empty_anchors,
|
||||
"promoted_to_draft": promoted,
|
||||
"dry_run": req.dry_run, "errors": errors[-50:],
|
||||
}
|
||||
logger.info("Anchor backfill %s completed: %d/%d updated (%d with anchors, %d empty, %d promoted)",
|
||||
backfill_id, updated, total, with_anchors, empty_anchors, promoted)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Anchor backfill %s failed: %s", backfill_id, e)
|
||||
_anchor_backfill_status[backfill_id] = {"status": "failed", "error": str(e)}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/generate/backfill-anchors")
|
||||
async def start_anchor_backfill(req: AnchorBackfillRequest):
|
||||
"""Backfill open_anchors (OWASP/NIST/ENISA references) for controls without anchors.
|
||||
|
||||
Uses RAG-internal search (Stage A) to find open-source framework references.
|
||||
Controls generated with skip_web_search=true have empty open_anchors.
|
||||
Default is dry_run=True (preview only, no DB changes).
|
||||
"""
|
||||
import uuid
|
||||
backfill_id = str(uuid.uuid4())[:8]
|
||||
_anchor_backfill_status[backfill_id] = {"status": "starting"}
|
||||
asyncio.create_task(_run_anchor_backfill(req, backfill_id))
|
||||
return {
|
||||
"status": "running",
|
||||
"backfill_id": backfill_id,
|
||||
"message": f"Anchor backfill started. Poll /generate/anchor-backfill-status/{backfill_id}",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/generate/anchor-backfill-status/{backfill_id}")
|
||||
async def get_anchor_backfill_status(backfill_id: str):
|
||||
"""Get status of an anchor backfill job."""
|
||||
status = _anchor_backfill_status.get(backfill_id)
|
||||
if not status:
|
||||
raise HTTPException(status_code=404, detail="Anchor backfill job not found")
|
||||
return status
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
"""Ingest BAG (Bundesarbeitsgericht) court decisions into RAG.
|
||||
|
||||
Downloads PDFs from bundesarbeitsgericht.de and uploads them to the
|
||||
bp_compliance_datenschutz Qdrant collection via the RAG-Service API.
|
||||
|
||||
These decisions are curated for IT/KI-Mitbestimmung relevance (§87 BetrVG).
|
||||
|
||||
Usage:
|
||||
python scripts/ingest_bag_urteile.py [--rag-url https://macmini:8097] [--dry-run]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Curated BAG decisions for IT/AI works council co-determination
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
BAG_DECISIONS = [
|
||||
# --- M365 / Copilot / Standardsoftware ---
|
||||
{
|
||||
"url": "https://www.bundesarbeitsgericht.de/entscheidung/1-abr-20-21/",
|
||||
"case_number": "1 ABR 20/21",
|
||||
"date": "2022-03-08",
|
||||
"subject": "Microsoft Office 365 — Mitbestimmung",
|
||||
"keywords": ["Microsoft 365", "Standardsoftware", "Ueberwachung", "§87 BetrVG"],
|
||||
},
|
||||
{
|
||||
"url": "https://www.bundesarbeitsgericht.de/entscheidung/1-abn-36-18/",
|
||||
"case_number": "1 ABN 36/18",
|
||||
"date": "2018-10-23",
|
||||
"subject": "Excel / Standardsoftware — keine Geringfuegigkeitsschwelle",
|
||||
"keywords": ["Excel", "Standardsoftware", "Geringfuegigkeit", "§87 BetrVG"],
|
||||
},
|
||||
{
|
||||
"url": "https://www.bundesarbeitsgericht.de/entscheidung/1-abr-45-11/",
|
||||
"case_number": "1 ABR 45/11",
|
||||
"date": "2012-09-25",
|
||||
"subject": "SAP ERP im Personalwesen",
|
||||
"keywords": ["SAP", "ERP", "Personalwesen", "Verhaltenskontrolle", "§87 BetrVG"],
|
||||
},
|
||||
{
|
||||
"url": "https://www.bundesarbeitsgericht.de/entscheidung/1-abr-31-19/",
|
||||
"case_number": "1 ABR 31/19",
|
||||
"date": "2021-01-27",
|
||||
"subject": "E-Mail-Kommunikationssoftware — Mitbestimmung",
|
||||
"keywords": ["E-Mail", "Kommunikation", "Software", "§87 BetrVG"],
|
||||
},
|
||||
{
|
||||
"url": "https://www.bundesarbeitsgericht.de/entscheidung/1-abr-13-17/",
|
||||
"case_number": "1 ABR 13/17",
|
||||
"date": "2019-07-09",
|
||||
"subject": "IT-System fuer Mitarbeiterbefragung",
|
||||
"keywords": ["Mitarbeiterbefragung", "Feedback", "technische Einrichtung", "§87 BetrVG"],
|
||||
},
|
||||
{
|
||||
"url": "https://www.bundesarbeitsgericht.de/entscheidung/1-abr-16-23/",
|
||||
"case_number": "1 ABR 16/23",
|
||||
"date": "2024-07-16",
|
||||
"subject": "Headset-System — Geraetenutzungsdaten",
|
||||
"keywords": ["Headset", "Geraetenutzung", "Ueberwachung", "§87 BetrVG"],
|
||||
},
|
||||
# --- Ueberwachung, Social, Drittplattformen ---
|
||||
{
|
||||
"url": "https://www.bundesarbeitsgericht.de/entscheidung/1-abr-7-15/",
|
||||
"case_number": "1 ABR 7/15",
|
||||
"date": "2016-12-13",
|
||||
"subject": "Facebook-Seite — indirekte Leistungsueberwachung",
|
||||
"keywords": ["Facebook", "Social Media", "Besucherbeitraege", "Ueberwachung", "§87 BetrVG"],
|
||||
},
|
||||
{
|
||||
"url": "https://www.bundesarbeitsgericht.de/entscheidung/1-abr-43-12/",
|
||||
"case_number": "1 ABR 43/12",
|
||||
"date": "2013-12-10",
|
||||
"subject": "Google Maps — indirekte Ueberwachung / Definition Ueberwachung",
|
||||
"keywords": ["Google Maps", "Routenplaner", "indirekte Ueberwachung", "Definition", "§87 BetrVG"],
|
||||
},
|
||||
{
|
||||
"url": "https://www.bundesarbeitsgericht.de/entscheidung/1-abr-68-13/",
|
||||
"case_number": "1 ABR 68/13",
|
||||
"date": "2015-07-21",
|
||||
"subject": "Ueberwachung durch technische Einrichtung eines Dritten (SaaS/Cloud)",
|
||||
"keywords": ["Drittsystem", "SaaS", "Cloud", "Ueberwachung", "§87 BetrVG"],
|
||||
},
|
||||
# --- Video, Belastung, Leistungskennzahlen ---
|
||||
{
|
||||
"url": "https://www.bundesarbeitsgericht.de/entscheidung/1-abr-78-11/",
|
||||
"case_number": "1 ABR 78/11",
|
||||
"date": "2012-12-11",
|
||||
"subject": "Videoueberwachung — Grundsatzentscheidung",
|
||||
"keywords": ["Videoueberwachung", "Kamera", "Arbeitsplatz", "§87 BetrVG"],
|
||||
},
|
||||
{
|
||||
"url": "https://www.bundesarbeitsgericht.de/entscheidung/1-abr-46-15/",
|
||||
"case_number": "1 ABR 46/15",
|
||||
"date": "2017-04-25",
|
||||
"subject": "Belastungsstatistik — dauerhafte Kennzahlenueberwachung",
|
||||
"keywords": ["Belastungsstatistik", "Kennzahlen", "Analytics", "Persoenlichkeitsrecht", "§87 BetrVG"],
|
||||
},
|
||||
# --- Negative / abgrenzende Faelle ---
|
||||
{
|
||||
"url": "https://www.bundesarbeitsgericht.de/entscheidung/1-abr-32-16/",
|
||||
"case_number": "1 ABR 32/16",
|
||||
"date": "2017-12-19",
|
||||
"subject": "Anti-Terror-Listen — keine Mitbestimmung",
|
||||
"keywords": ["Anti-Terror", "Sanktionsliste", "keine Mitbestimmung", "Abgrenzung", "§87 BetrVG"],
|
||||
},
|
||||
{
|
||||
"url": "https://www.bundesarbeitsgericht.de/entscheidung/1-abr-22-21/",
|
||||
"case_number": "1 ABR 22/21",
|
||||
"date": "2022-09-13",
|
||||
"subject": "Elektronische Arbeitszeiterfassung — Initiativrecht",
|
||||
"keywords": ["Arbeitszeiterfassung", "Initiativrecht", "digitale Systeme", "§87 BetrVG"],
|
||||
},
|
||||
# --- Historische Grundsatzentscheidungen ---
|
||||
{
|
||||
"url": "https://www.bundesarbeitsgericht.de/entscheidung/1-abr-43-81/",
|
||||
"case_number": "1 ABR 43/81",
|
||||
"date": "1983-12-06",
|
||||
"subject": "Grundsatz technische Ueberwachung — Eignung genuegt",
|
||||
"keywords": ["Grundsatz", "Eignung", "technische Einrichtung", "§87 BetrVG"],
|
||||
},
|
||||
{
|
||||
"url": "https://www.bundesarbeitsgericht.de/entscheidung/1-abr-23-82/",
|
||||
"case_number": "1 ABR 23/82",
|
||||
"date": "1984-09-14",
|
||||
"subject": "Erste Grundlinie IT-Systeme",
|
||||
"keywords": ["IT-System", "Grundlinie", "technische Einrichtung", "§87 BetrVG"],
|
||||
},
|
||||
# --- E-Mail / Internet ---
|
||||
{
|
||||
"url": "https://www.bundesarbeitsgericht.de/entscheidung/1-abr-46-10/",
|
||||
"case_number": "1 ABR 46/10",
|
||||
"date": "2012-02-07",
|
||||
"subject": "Internet- und E-Mail-Nutzung — Kommunikationsdaten",
|
||||
"keywords": ["Internet", "E-Mail", "Kommunikationsdaten", "Auswertung", "§87 BetrVG"],
|
||||
},
|
||||
# --- HR / Bewertungssysteme ---
|
||||
{
|
||||
"url": "https://www.bundesarbeitsgericht.de/entscheidung/1-abr-40-07/",
|
||||
"case_number": "1 ABR 40/07",
|
||||
"date": "2008-07-22",
|
||||
"subject": "Beurteilungssysteme — §94/§95 BetrVG",
|
||||
"keywords": ["Beurteilung", "Bewertungssystem", "HR", "§94 BetrVG", "§95 BetrVG"],
|
||||
},
|
||||
{
|
||||
"url": "https://www.bundesarbeitsgericht.de/entscheidung/1-abr-16-07/",
|
||||
"case_number": "1 ABR 16/07",
|
||||
"date": "2008-03-18",
|
||||
"subject": "Personalfrageboegen — Bewertung",
|
||||
"keywords": ["Personalfragebogen", "Bewertung", "HR-Tools", "§94 BetrVG"],
|
||||
},
|
||||
# --- Video / physische Ueberwachung ---
|
||||
{
|
||||
"url": "https://www.bundesarbeitsgericht.de/entscheidung/1-abr-21-03/",
|
||||
"case_number": "1 ABR 21/03",
|
||||
"date": "2004-06-29",
|
||||
"subject": "Videoueberwachung Arbeitsplatz",
|
||||
"keywords": ["Video", "Kamera", "Arbeitsplatz", "Ueberwachung", "§87 BetrVG"],
|
||||
},
|
||||
# --- Zustaendigkeit ---
|
||||
{
|
||||
"url": "https://www.bundesarbeitsgericht.de/entscheidung/1-abr-2-05/",
|
||||
"case_number": "1 ABR 2/05",
|
||||
"date": "2006-05-03",
|
||||
"subject": "Zustaendigkeit Betriebsrat bei konzernweiten Tools",
|
||||
"keywords": ["Zustaendigkeit", "Konzern", "Gesamtbetriebsrat", "§87 BetrVG"],
|
||||
},
|
||||
{
|
||||
"url": "https://www.bundesarbeitsgericht.de/entscheidung/1-abr-58-04/",
|
||||
"case_number": "1 ABR 58/04",
|
||||
"date": "2006-03-28",
|
||||
"subject": "Mitbestimmung bei Einfuehrung technischer Systeme",
|
||||
"keywords": ["Systemeinführung", "technische Systeme", "Mitbestimmung", "§87 BetrVG"],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def normalize_case_number(case_number: str) -> str:
|
||||
"""Normalize case number for use as regulation_id."""
|
||||
return re.sub(r"[^a-z0-9]", "_", case_number.lower()).strip("_")
|
||||
|
||||
|
||||
def download_decision(url: str, client: httpx.Client) -> bytes:
|
||||
"""Download a BAG decision page as HTML."""
|
||||
resp = client.get(url, follow_redirects=True)
|
||||
resp.raise_for_status()
|
||||
return resp.content
|
||||
|
||||
|
||||
def upload_to_rag(
|
||||
file_bytes: bytes,
|
||||
filename: str,
|
||||
metadata: dict,
|
||||
rag_url: str,
|
||||
client: httpx.Client,
|
||||
) -> dict:
|
||||
"""Upload a document to the RAG service."""
|
||||
files = {"file": (filename, file_bytes, "text/html")}
|
||||
data = {
|
||||
"collection": "bp_compliance_datenschutz",
|
||||
"data_type": "compliance_datenschutz",
|
||||
"bundesland": "bund",
|
||||
"use_case": "court_decision",
|
||||
"year": metadata.get("date", "2024")[:4],
|
||||
"chunk_strategy": "legal",
|
||||
"chunk_size": "512",
|
||||
"chunk_overlap": "50",
|
||||
"metadata_json": json.dumps(metadata),
|
||||
}
|
||||
resp = client.post(f"{rag_url}/api/v1/documents/upload", files=files, data=data)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Ingest BAG court decisions into RAG")
|
||||
parser.add_argument("--rag-url", default="https://macmini:8097", help="RAG service URL")
|
||||
parser.add_argument("--dry-run", action="store_true", help="Download only, don't upload")
|
||||
args = parser.parse_args()
|
||||
|
||||
client = httpx.Client(timeout=60, verify=False)
|
||||
stats = {"downloaded": 0, "uploaded": 0, "errors": 0}
|
||||
|
||||
for decision in BAG_DECISIONS:
|
||||
case_id = normalize_case_number(decision["case_number"])
|
||||
print(f"\n--- {decision['case_number']}: {decision['subject']} ---")
|
||||
|
||||
# Download
|
||||
try:
|
||||
html_bytes = download_decision(decision["url"], client)
|
||||
stats["downloaded"] += 1
|
||||
print(f" Downloaded: {len(html_bytes)} bytes")
|
||||
except Exception as e:
|
||||
print(f" ERROR downloading: {e}")
|
||||
stats["errors"] += 1
|
||||
continue
|
||||
|
||||
if args.dry_run:
|
||||
continue
|
||||
|
||||
# Upload
|
||||
metadata = {
|
||||
"regulation_id": f"bag_{case_id}",
|
||||
"regulation_name_de": f"BAG {decision['case_number']} — {decision['subject']}",
|
||||
"category": "arbeitsrecht",
|
||||
"source": "bundesarbeitsgericht.de",
|
||||
"doc_type": "court_decision",
|
||||
"license": "public_domain_§5_UrhG",
|
||||
"court": "BAG",
|
||||
"case_number": decision["case_number"],
|
||||
"date": decision["date"],
|
||||
"subject_matter": decision["subject"],
|
||||
"keywords": decision["keywords"],
|
||||
}
|
||||
|
||||
try:
|
||||
result = upload_to_rag(
|
||||
html_bytes,
|
||||
f"bag_{case_id}.html",
|
||||
metadata,
|
||||
args.rag_url,
|
||||
client,
|
||||
)
|
||||
stats["uploaded"] += 1
|
||||
print(f" Uploaded: {result.get('chunks_count', '?')} chunks, doc_id={result.get('document_id', '?')}")
|
||||
except Exception as e:
|
||||
print(f" ERROR uploading: {e}")
|
||||
stats["errors"] += 1
|
||||
|
||||
time.sleep(1) # Rate limiting
|
||||
|
||||
print(f"\n=== Done: {stats['downloaded']} downloaded, {stats['uploaded']} uploaded, {stats['errors']} errors ===")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -2,19 +2,19 @@
|
||||
Anchor Finder — finds open-source references (OWASP, NIST, ENISA) for controls.
|
||||
|
||||
Two-stage search:
|
||||
Stage A: RAG-internal search for open-source chunks matching the control topic
|
||||
Stage A: Direct Qdrant vector search for open-source chunks matching the control topic
|
||||
Stage B: Web search via DuckDuckGo Instant Answer API (no API key needed)
|
||||
|
||||
Only open-source references (Rule 1+2) are accepted as anchors.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from .rag_client import ComplianceRAGClient, get_rag_client
|
||||
from .control_generator import (
|
||||
GeneratedControl,
|
||||
REGULATION_LICENSE_MAP,
|
||||
@@ -25,9 +25,15 @@ from .control_generator import (
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
QDRANT_URL = os.getenv("QDRANT_URL", "http://qdrant:6333")
|
||||
EMBEDDING_URL = os.getenv("EMBEDDING_URL", "http://embedding-service:8087")
|
||||
|
||||
# Regulation codes that are safe to reference as open anchors (Rule 1+2)
|
||||
_OPEN_SOURCE_RULES = {1, 2}
|
||||
|
||||
# Collections to search for anchors (open-source frameworks)
|
||||
_ANCHOR_COLLECTIONS = ["bp_compliance_ce", "bp_compliance_datenschutz"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class OpenAnchor:
|
||||
@@ -39,8 +45,9 @@ class OpenAnchor:
|
||||
class AnchorFinder:
|
||||
"""Finds open-source references to anchor generated controls."""
|
||||
|
||||
def __init__(self, rag_client: Optional[ComplianceRAGClient] = None):
|
||||
self.rag = rag_client or get_rag_client()
|
||||
def __init__(self, rag_client=None):
|
||||
# rag_client kept for backwards compat but no longer used
|
||||
pass
|
||||
|
||||
async def find_anchors(
|
||||
self,
|
||||
@@ -49,8 +56,8 @@ class AnchorFinder:
|
||||
min_anchors: int = 2,
|
||||
) -> List[OpenAnchor]:
|
||||
"""Find open-source anchors for a control."""
|
||||
# Stage A: RAG-internal search
|
||||
anchors = await self._search_rag_for_open_anchors(control)
|
||||
# Stage A: Direct Qdrant vector search
|
||||
anchors = await self._search_qdrant_for_open_anchors(control)
|
||||
|
||||
# Stage B: Web search if not enough anchors
|
||||
if len(anchors) < min_anchors and not skip_web:
|
||||
@@ -63,45 +70,104 @@ class AnchorFinder:
|
||||
|
||||
return anchors
|
||||
|
||||
async def _search_rag_for_open_anchors(self, control: GeneratedControl) -> List[OpenAnchor]:
|
||||
"""Search RAG for chunks from open sources matching the control topic."""
|
||||
async def _get_embedding(self, text: str) -> list:
|
||||
"""Get embedding vector via embedding service."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.post(
|
||||
f"{EMBEDDING_URL}/embed",
|
||||
json={"texts": [text]},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
embeddings = resp.json().get("embeddings", [])
|
||||
return embeddings[0] if embeddings else []
|
||||
except Exception as e:
|
||||
logger.warning("Embedding request failed: %s", e)
|
||||
return []
|
||||
|
||||
async def _search_qdrant_for_open_anchors(self, control: GeneratedControl) -> List[OpenAnchor]:
|
||||
"""Search Qdrant directly for chunks from open sources matching the control topic."""
|
||||
# Build search query from control title + first 3 tags
|
||||
tags_str = " ".join(control.tags[:3]) if control.tags else ""
|
||||
query = f"{control.title} {tags_str}".strip()
|
||||
|
||||
results = await self.rag.search_with_rerank(
|
||||
query=query,
|
||||
collection="bp_compliance_ce",
|
||||
top_k=15,
|
||||
)
|
||||
# Get embedding for query
|
||||
embedding = await self._get_embedding(query)
|
||||
if not embedding:
|
||||
return []
|
||||
|
||||
anchors: List[OpenAnchor] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
for r in results:
|
||||
if not r.regulation_code:
|
||||
for collection in _ANCHOR_COLLECTIONS:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
resp = await client.post(
|
||||
f"{QDRANT_URL}/collections/{collection}/points/search",
|
||||
json={
|
||||
"vector": embedding,
|
||||
"limit": 20,
|
||||
"with_payload": True,
|
||||
"with_vector": False,
|
||||
},
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
logger.warning("Qdrant search %s failed: %d", collection, resp.status_code)
|
||||
continue
|
||||
|
||||
results = resp.json().get("result", [])
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("Qdrant search error for %s: %s", collection, e)
|
||||
continue
|
||||
|
||||
# Only accept open-source references
|
||||
license_info = _classify_regulation(r.regulation_code)
|
||||
if license_info.get("rule") not in _OPEN_SOURCE_RULES:
|
||||
continue
|
||||
for hit in results:
|
||||
payload = hit.get("payload", {})
|
||||
# Qdrant payloads use regulation_id (not regulation_code)
|
||||
regulation_code = (
|
||||
payload.get("regulation_id", "")
|
||||
or payload.get("regulation_code", "")
|
||||
or payload.get("metadata", {}).get("regulation_id", "")
|
||||
)
|
||||
if not regulation_code:
|
||||
continue
|
||||
|
||||
# Build reference key for dedup
|
||||
ref = r.article or r.category or ""
|
||||
key = f"{r.regulation_code}:{ref}"
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
# Only accept open-source references
|
||||
license_info = _classify_regulation(regulation_code)
|
||||
if license_info.get("rule") not in _OPEN_SOURCE_RULES:
|
||||
continue
|
||||
|
||||
framework_name = license_info.get("name", r.regulation_name or r.regulation_short or r.regulation_code)
|
||||
url = r.source_url or self._build_reference_url(r.regulation_code, ref)
|
||||
# Build reference key for dedup
|
||||
article = payload.get("article", "") or payload.get("category", "") or ""
|
||||
ref = article
|
||||
key = f"{regulation_code}:{ref}"
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
|
||||
anchors.append(OpenAnchor(
|
||||
framework=framework_name,
|
||||
ref=ref,
|
||||
url=url,
|
||||
))
|
||||
reg_name = (
|
||||
payload.get("regulation_name_de", "")
|
||||
or payload.get("regulation_name_en", "")
|
||||
or payload.get("guideline_name", "")
|
||||
)
|
||||
reg_short = payload.get("regulation_short", "")
|
||||
source_url = (
|
||||
payload.get("download_url", "")
|
||||
or payload.get("source_url", "")
|
||||
or payload.get("source", "")
|
||||
)
|
||||
|
||||
framework_name = license_info.get("name", reg_name or reg_short or regulation_code)
|
||||
url = source_url or self._build_reference_url(regulation_code, ref)
|
||||
|
||||
anchors.append(OpenAnchor(
|
||||
framework=framework_name,
|
||||
ref=ref,
|
||||
url=url,
|
||||
))
|
||||
|
||||
if len(anchors) >= 5:
|
||||
break
|
||||
|
||||
if len(anchors) >= 5:
|
||||
break
|
||||
|
||||
@@ -489,7 +489,7 @@ class GeneratorConfig(BaseModel):
|
||||
max_controls: int = 0 # 0 = unlimited (process ALL chunks)
|
||||
max_chunks: int = 0 # 0 = unlimited; >0 = stop after N chunks (respects document boundaries)
|
||||
skip_processed: bool = True
|
||||
skip_web_search: bool = False
|
||||
skip_web_search: bool = True # Default True — Anchor-Search verlangsamt 5x, nachtraeglich batchen
|
||||
dry_run: bool = False
|
||||
existing_job_id: Optional[str] = None # If set, reuse this job instead of creating a new one
|
||||
regulation_filter: Optional[List[str]] = None # Only process chunks matching these regulation_code prefixes
|
||||
@@ -544,6 +544,8 @@ class GeneratorResult:
|
||||
controls_too_close: int = 0
|
||||
controls_duplicates_found: int = 0
|
||||
controls_qa_fixed: int = 0
|
||||
controls_stored: int = 0 # Actually persisted to DB
|
||||
controls_store_failed: int = 0 # Generated but failed to persist
|
||||
chunks_skipped_prefilter: int = 0
|
||||
errors: list = field(default_factory=list)
|
||||
controls: list = field(default_factory=list)
|
||||
@@ -645,6 +647,13 @@ async def _llm_anthropic(prompt: str, system_prompt: Optional[str] = None, max_r
|
||||
json=payload,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
# Retry on transient HTTP errors
|
||||
if resp.status_code in (429, 500, 502, 503, 504) and attempt < max_retries:
|
||||
wait = 2 ** attempt
|
||||
logger.warning("Anthropic API %d (transient) — retry in %ds...", resp.status_code, wait)
|
||||
import asyncio
|
||||
await asyncio.sleep(wait)
|
||||
continue
|
||||
logger.error("Anthropic API %d: %s", resp.status_code, resp.text[:300])
|
||||
return ""
|
||||
data = resp.json()
|
||||
@@ -1517,9 +1526,22 @@ Gib ein JSON-Array zurueck mit GENAU {len(chunks)} Elementen. Fuer Aspekte ohne
|
||||
final: list[Optional[GeneratedControl]] = []
|
||||
for i in range(len(batch_items)):
|
||||
control = all_controls.get(i)
|
||||
if not control or (not control.title and not control.objective):
|
||||
# Filter empty or invalid controls (LLM returned None/empty)
|
||||
if not control:
|
||||
final.append(None)
|
||||
continue
|
||||
title_invalid = not control.title or control.title.strip().lower() in ("none", "null", "")
|
||||
obj_invalid = not control.objective or control.objective.strip().lower() in ("none", "null", "")
|
||||
if title_invalid and obj_invalid:
|
||||
logger.warning("Leerer Control gefiltert (title=%s, objective=%s) — wird nicht gespeichert",
|
||||
control.title, control.objective)
|
||||
final.append(None)
|
||||
continue
|
||||
# Clean up "None" strings from LLM
|
||||
if title_invalid:
|
||||
control.title = control.objective[:120] if control.objective else "Unbenannt"
|
||||
if obj_invalid:
|
||||
control.objective = control.title
|
||||
|
||||
if control.release_state == "too_close":
|
||||
final.append(control)
|
||||
@@ -1533,14 +1555,15 @@ Gib ein JSON-Array zurueck mit GENAU {len(chunks)} Elementen. Fuer Aspekte ohne
|
||||
final.append(control)
|
||||
continue
|
||||
|
||||
# Anchor search
|
||||
try:
|
||||
from .anchor_finder import AnchorFinder
|
||||
finder = AnchorFinder(self.rag)
|
||||
anchors = await finder.find_anchors(control, skip_web=config.skip_web_search)
|
||||
control.open_anchors = [asdict(a) if hasattr(a, '__dataclass_fields__') else a for a in anchors]
|
||||
except Exception as e:
|
||||
logger.warning("Anchor search failed: %s", e)
|
||||
# Anchor search — skip entirely when skip_web_search=true (backfilled later)
|
||||
if not config.skip_web_search:
|
||||
try:
|
||||
from .anchor_finder import AnchorFinder
|
||||
finder = AnchorFinder(self.rag)
|
||||
anchors = await finder.find_anchors(control, skip_web=False)
|
||||
control.open_anchors = [asdict(a) if hasattr(a, '__dataclass_fields__') else a for a in anchors]
|
||||
except Exception as e:
|
||||
logger.warning("Anchor search failed: %s", e)
|
||||
|
||||
# Release state
|
||||
if control.license_rule in (1, 2):
|
||||
@@ -1732,20 +1755,52 @@ Gib ein JSON-Array zurueck mit GENAU {len(chunks)} Elementen. Fuer Aspekte ohne
|
||||
)
|
||||
|
||||
def _generate_control_id(self, domain: str, db: Session) -> str:
|
||||
"""Generate next sequential control ID like AUTH-011."""
|
||||
"""Generate unique control ID using numeric MAX + collision guard.
|
||||
|
||||
Uses CAST to INTEGER for correct numeric ordering (not string sort).
|
||||
Falls back to UUID suffix if collision is detected.
|
||||
"""
|
||||
prefix = domain.upper()[:4]
|
||||
try:
|
||||
# Numeric ordering — CAST to INTEGER, not string sort
|
||||
result = db.execute(
|
||||
text("SELECT control_id FROM canonical_controls WHERE control_id LIKE :prefix ORDER BY control_id DESC LIMIT 1"),
|
||||
{"prefix": f"{prefix}-%"},
|
||||
text("""
|
||||
SELECT COALESCE(
|
||||
MAX(CAST(SUBSTRING(control_id FROM :prefix_len) AS INTEGER)),
|
||||
0
|
||||
) + 1
|
||||
FROM canonical_controls
|
||||
WHERE control_id ~ (:pattern)
|
||||
"""),
|
||||
{"prefix_len": len(prefix) + 2, "pattern": f"^{prefix}-[0-9]+$"},
|
||||
)
|
||||
row = result.fetchone()
|
||||
if row:
|
||||
last_num = int(row[0].split("-")[-1])
|
||||
return f"{prefix}-{last_num + 1:03d}"
|
||||
except Exception:
|
||||
pass
|
||||
return f"{prefix}-001"
|
||||
next_num = result.scalar() or 1
|
||||
candidate = f"{prefix}-{next_num:03d}"
|
||||
|
||||
# Collision guard — check if ID already exists
|
||||
exists = db.execute(
|
||||
text("SELECT 1 FROM canonical_controls WHERE control_id = :cid LIMIT 1"),
|
||||
{"cid": candidate},
|
||||
).fetchone()
|
||||
|
||||
if exists:
|
||||
# UUID suffix as fallback for race conditions
|
||||
suffix = uuid.uuid4().hex[:6]
|
||||
candidate = f"{prefix}-{next_num:03d}-{suffix}"
|
||||
logger.warning(
|
||||
"ID collision for %s-%03d — using unique suffix: %s",
|
||||
prefix, next_num, candidate,
|
||||
)
|
||||
|
||||
return candidate
|
||||
except Exception as e:
|
||||
# NEVER swallow silently — UUID as safe fallback
|
||||
fallback = f"{prefix}-{uuid.uuid4().hex[:8]}"
|
||||
logger.error(
|
||||
"Failed to generate control_id for domain %s: %s — using fallback %s",
|
||||
domain, e, fallback,
|
||||
)
|
||||
return fallback
|
||||
|
||||
# ── Stage QA: Automated Quality Validation ───────────────────────
|
||||
|
||||
@@ -1890,6 +1945,14 @@ Kategorien: {CATEGORY_LIST_STR}"""
|
||||
|
||||
def _store_control(self, control: GeneratedControl, job_id: str) -> Optional[str]:
|
||||
"""Persist a generated control to DB. Returns the control UUID or None."""
|
||||
# Pre-store quality guard — reject empty/invalid controls
|
||||
if not control.title or control.title.strip().lower() in ("none", "null", ""):
|
||||
logger.warning("Rejected control with empty/None title: %s", control.control_id)
|
||||
return None
|
||||
if not control.objective or control.objective.strip().lower() in ("none", "null", ""):
|
||||
logger.warning("Rejected control with empty/None objective: %s — %s", control.control_id, control.title)
|
||||
return None
|
||||
|
||||
try:
|
||||
# Get framework UUID
|
||||
fw_result = self.db.execute(
|
||||
@@ -1929,7 +1992,11 @@ Kategorien: {CATEGORY_LIST_STR}"""
|
||||
:target_audience, :pipeline_version,
|
||||
:applicable_industries, :applicable_company_size, :scope_conditions
|
||||
)
|
||||
ON CONFLICT (framework_id, control_id) DO NOTHING
|
||||
ON CONFLICT (framework_id, control_id) DO UPDATE SET
|
||||
updated_at = NOW(),
|
||||
title = EXCLUDED.title,
|
||||
objective = EXCLUDED.objective,
|
||||
generation_metadata = EXCLUDED.generation_metadata
|
||||
RETURNING id
|
||||
"""),
|
||||
{
|
||||
@@ -2169,12 +2236,21 @@ Kategorien: {CATEGORY_LIST_STR}"""
|
||||
if ctrl_uuid:
|
||||
path = control.generation_metadata.get("processing_path", "structured_batch")
|
||||
self._mark_chunk_processed(chunk, lic_info, path, [ctrl_uuid], job_id)
|
||||
result.controls_generated += 1
|
||||
result.controls_stored += 1
|
||||
controls_count += 1
|
||||
else:
|
||||
self._mark_chunk_processed(chunk, lic_info, "store_failed", [], job_id)
|
||||
# CRITICAL FIX: Do NOT mark chunk as processed — allow retry
|
||||
logger.error(
|
||||
"STORE_FAILED: Control '%s' (%s) nicht gespeichert — Chunk bleibt unverarbeitet fuer Retry",
|
||||
control.control_id, control.title[:60],
|
||||
)
|
||||
result.controls_store_failed += 1
|
||||
else:
|
||||
result.controls_generated += 1
|
||||
controls_count += 1
|
||||
|
||||
result.controls_generated += 1
|
||||
result.controls.append(asdict(control))
|
||||
controls_count += 1
|
||||
|
||||
if self._existing_controls is not None:
|
||||
self._existing_controls.append({
|
||||
@@ -2187,10 +2263,18 @@ Kategorien: {CATEGORY_LIST_STR}"""
|
||||
try:
|
||||
# Progress logging every 50 chunks
|
||||
if i > 0 and i % 50 == 0:
|
||||
store_rate = (result.controls_stored / max(result.controls_generated, 1)) * 100 if result.controls_generated > 0 else 100
|
||||
logger.info(
|
||||
"Progress: %d/%d chunks processed, %d controls generated, %d skipped by prefilter",
|
||||
i, len(chunks), controls_count, chunks_skipped_prefilter,
|
||||
"Progress: %d/%d chunks | %d generated | %d stored (%.0f%%) | %d store_failed | %d skipped",
|
||||
i, len(chunks), result.controls_generated, result.controls_stored,
|
||||
store_rate, result.controls_store_failed, chunks_skipped_prefilter,
|
||||
)
|
||||
# ALARM bei niedriger Store-Rate
|
||||
if result.controls_generated > 10 and store_rate < 80:
|
||||
logger.error(
|
||||
"ALARM: Store-Erfolgsrate nur %.0f%% — moeglicherweise ID-Kollisionen!",
|
||||
store_rate,
|
||||
)
|
||||
self._update_job(job_id, result)
|
||||
|
||||
# Stage 1.5: Local LLM pre-filter — skip chunks without requirements
|
||||
@@ -2235,11 +2319,38 @@ Kategorien: {CATEGORY_LIST_STR}"""
|
||||
await _flush_batch()
|
||||
|
||||
result.chunks_skipped_prefilter = chunks_skipped_prefilter
|
||||
|
||||
# Post-Job Validierung — DB-Realitaet pruefen
|
||||
try:
|
||||
actual_stored = self.db.execute(
|
||||
text("SELECT count(*) FROM canonical_controls WHERE generation_metadata::text LIKE :jid"),
|
||||
{"jid": f"%{job_id}%"},
|
||||
).scalar() or 0
|
||||
except Exception:
|
||||
actual_stored = -1
|
||||
|
||||
final_store_rate = (result.controls_stored / max(result.controls_generated, 1)) * 100 if result.controls_generated > 0 else 0
|
||||
|
||||
logger.info(
|
||||
"Pipeline complete: %d controls generated, %d chunks skipped by prefilter, %d total chunks",
|
||||
controls_count, chunks_skipped_prefilter, len(chunks),
|
||||
"Pipeline complete: %d chunks | %d generated | %d stored (%.0f%%) | %d store_failed | %d skipped | DB actual: %d",
|
||||
len(chunks), result.controls_generated, result.controls_stored,
|
||||
final_store_rate, result.controls_store_failed,
|
||||
chunks_skipped_prefilter, actual_stored,
|
||||
)
|
||||
|
||||
if result.controls_store_failed > 0:
|
||||
logger.error(
|
||||
"WARNUNG: %d Controls konnten NICHT gespeichert werden! "
|
||||
"Diese Chunks bleiben unverarbeitet und muessen erneut verarbeitet werden.",
|
||||
result.controls_store_failed,
|
||||
)
|
||||
|
||||
if result.controls_generated > 0 and final_store_rate < 90:
|
||||
logger.error(
|
||||
"KRITISCH: Store-Rate nur %.0f%% — %d von %d Controls verloren!",
|
||||
final_store_rate, result.controls_store_failed, result.controls_generated,
|
||||
)
|
||||
|
||||
result.status = "completed"
|
||||
|
||||
except Exception as e:
|
||||
@@ -2292,14 +2403,15 @@ Kategorien: {CATEGORY_LIST_STR}"""
|
||||
control.generation_metadata["similar_controls"] = duplicates
|
||||
return control
|
||||
|
||||
# Stage 5: Anchor Search (imported from anchor_finder)
|
||||
try:
|
||||
from .anchor_finder import AnchorFinder
|
||||
finder = AnchorFinder(self.rag)
|
||||
anchors = await finder.find_anchors(control, skip_web=config.skip_web_search)
|
||||
control.open_anchors = [asdict(a) if hasattr(a, '__dataclass_fields__') else a for a in anchors]
|
||||
except Exception as e:
|
||||
logger.warning("Anchor search failed: %s", e)
|
||||
# Stage 5: Anchor Search — skip entirely when skip_web_search=true (backfilled later)
|
||||
if not config.skip_web_search:
|
||||
try:
|
||||
from .anchor_finder import AnchorFinder
|
||||
finder = AnchorFinder(self.rag)
|
||||
anchors = await finder.find_anchors(control, skip_web=False)
|
||||
control.open_anchors = [asdict(a) if hasattr(a, '__dataclass_fields__') else a for a in anchors]
|
||||
except Exception as e:
|
||||
logger.warning("Anchor search failed: %s", e)
|
||||
|
||||
# Determine release state
|
||||
if control.license_rule in (1, 2):
|
||||
|
||||
+5
-2
@@ -61,6 +61,7 @@ services:
|
||||
- "3008:3008" # Admin Core
|
||||
- "3010:3010" # Portal Dashboard
|
||||
- "8011:8011" # Compliance Docs (MkDocs)
|
||||
- "3012:3012" # Pitch Deck
|
||||
volumes:
|
||||
- ./nginx/conf.d:/etc/nginx/conf.d:ro
|
||||
- vault_certs:/etc/nginx/certs:ro
|
||||
@@ -873,11 +874,13 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
container_name: bp-core-pitch-deck
|
||||
platform: linux/arm64
|
||||
ports:
|
||||
- "3012:3000"
|
||||
expose:
|
||||
- "3000"
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
DATABASE_URL: postgres://${POSTGRES_USER:-breakpilot}:${POSTGRES_PASSWORD:-breakpilot123}@postgres:5432/${POSTGRES_DB:-breakpilot_db}
|
||||
PITCH_JWT_SECRET: ${PITCH_JWT_SECRET:-7025f5da6d2ea384353ea6debddae0ea9e2dbca151a1df4b65be8cb80a5cf002}
|
||||
PITCH_ADMIN_SECRET: ${PITCH_ADMIN_SECRET:-40df9e6f2ca2e90729030af37bf79199710b09c898cac9df}
|
||||
LITELLM_URL: ${LITELLM_URL:-https://llm-dev.meghsakha.com}
|
||||
LITELLM_MODEL: ${LITELLM_MODEL:-gpt-oss-120b}
|
||||
LITELLM_API_KEY: ${LITELLM_API_KEY:-sk-0nAyxaMVbIqmz_ntnndzag}
|
||||
|
||||
@@ -7,10 +7,10 @@ BreakPilot verwendet zwei Umgebungen:
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────┐
|
||||
│ Development │───── git push ────▶│ Production │
|
||||
│ (Mac Mini) │ │ (Coolify) │
|
||||
│ (Mac Mini) │ │ (Orca) │
|
||||
└─────────────────┘ └─────────────────┘
|
||||
Lokale Automatisch
|
||||
Entwicklung via Coolify
|
||||
Entwicklung via Orca
|
||||
```
|
||||
|
||||
## Umgebungen
|
||||
@@ -32,21 +32,21 @@ BreakPilot verwendet zwei Umgebungen:
|
||||
ssh macmini "cd ~/Projekte/breakpilot-core && /usr/local/bin/docker compose up -d"
|
||||
```
|
||||
|
||||
### Production (Coolify)
|
||||
### Production (Orca)
|
||||
|
||||
**Zweck:** Live-System
|
||||
|
||||
| Eigenschaft | Wert |
|
||||
|-------------|------|
|
||||
| Git Branch | `main` |
|
||||
| Deployment | Coolify (automatisch bei Push auf gitea) |
|
||||
| Deployment | Orca (automatisch bei Push auf gitea) |
|
||||
| Database | Externe PostgreSQL (TLS) |
|
||||
| Debug | Deaktiviert |
|
||||
|
||||
**Deploy:**
|
||||
```bash
|
||||
git push origin main && git push gitea main
|
||||
# Coolify baut und deployt automatisch
|
||||
# Orca baut und deployt automatisch
|
||||
```
|
||||
|
||||
## Docker Compose Architektur
|
||||
@@ -54,10 +54,10 @@ git push origin main && git push gitea main
|
||||
```
|
||||
docker-compose.yml ← Basis-Konfiguration (lokal, arm64)
|
||||
│
|
||||
└── docker-compose.coolify.yml ← Production Override (amd64)
|
||||
└── docker-compose.orca.yml ← Production Override (amd64)
|
||||
```
|
||||
|
||||
Coolify verwendet automatisch beide Compose-Files fuer den Production-Build.
|
||||
Orca verwendet automatisch beide Compose-Files fuer den Production-Build.
|
||||
|
||||
## Secrets Management
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@ Uebersicht ueber den Deployment-Prozess fuer BreakPilot.
|
||||
|
||||
| Repo | Deployment | Trigger | Compose File |
|
||||
|------|-----------|---------|--------------|
|
||||
| **breakpilot-core** | Coolify (automatisch) | Push auf `coolify` Branch | `docker-compose.coolify.yml` |
|
||||
| **breakpilot-compliance** | Coolify (automatisch) | Push auf `main` Branch | `docker-compose.yml` + `docker-compose.coolify.yml` |
|
||||
| **breakpilot-core** | Orca (automatisch) | Push auf `orca` Branch | `docker-compose.orca.yml` |
|
||||
| **breakpilot-compliance** | Orca (automatisch) | Push auf `main` Branch | `docker-compose.yml` + `docker-compose.orca.yml` |
|
||||
| **breakpilot-lehrer** | Mac Mini (lokal) | Manuell `docker compose` | `docker-compose.yml` |
|
||||
|
||||
## Deployment-Architektur
|
||||
@@ -16,7 +16,7 @@ Uebersicht ueber den Deployment-Prozess fuer BreakPilot.
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Entwickler-MacBook │
|
||||
│ │
|
||||
│ breakpilot-core/ → git push gitea coolify │
|
||||
│ breakpilot-core/ → git push gitea orca │
|
||||
│ breakpilot-compliance/ → git push gitea main │
|
||||
│ breakpilot-lehrer/ → git push + ssh macmini docker ... │
|
||||
│ │
|
||||
@@ -26,11 +26,11 @@ Uebersicht ueber den Deployment-Prozess fuer BreakPilot.
|
||||
│ │
|
||||
▼ ▼
|
||||
┌───────────────────────────┐ ┌───────────────────────────┐
|
||||
│ Coolify (Production) │ │ Mac Mini (Lokal/Dev) │
|
||||
│ Orca (Production) │ │ Mac Mini (Lokal/Dev) │
|
||||
│ │ │ │
|
||||
│ Gitea Actions │ │ breakpilot-lehrer │
|
||||
│ ├── Tests │ │ ├── studio-v2 │
|
||||
│ └── Coolify API Deploy │ │ ├── klausur-service │
|
||||
│ └── Orca API Deploy │ │ ├── klausur-service │
|
||||
│ │ │ ├── backend-lehrer │
|
||||
│ Core Services: │ │ └── voice-service │
|
||||
│ ├── consent-service │ │ │
|
||||
@@ -47,23 +47,23 @@ Uebersicht ueber den Deployment-Prozess fuer BreakPilot.
|
||||
└───────────────────────────┘ └───────────────────────────┘
|
||||
```
|
||||
|
||||
## breakpilot-core → Coolify
|
||||
## breakpilot-core → Orca
|
||||
|
||||
### Pipeline
|
||||
|
||||
```yaml
|
||||
# .gitea/workflows/deploy-coolify.yml
|
||||
# .gitea/workflows/deploy-orca.yml
|
||||
on:
|
||||
push:
|
||||
branches: [coolify]
|
||||
branches: [orca]
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Deploy via Coolify API
|
||||
# Triggert Coolify Build + Deploy ueber API
|
||||
# Secrets: COOLIFY_API_TOKEN, COOLIFY_RESOURCE_UUID, COOLIFY_BASE_URL
|
||||
- name: Deploy via Orca API
|
||||
# Triggert Orca Build + Deploy ueber API
|
||||
# Secrets: ORCA_API_TOKEN, ORCA_RESOURCE_UUID, ORCA_BASE_URL
|
||||
```
|
||||
|
||||
### Workflow
|
||||
@@ -74,13 +74,13 @@ jobs:
|
||||
git push origin main && git push gitea main
|
||||
|
||||
# 3. Fuer Production-Deploy:
|
||||
git push gitea coolify
|
||||
git push gitea orca
|
||||
|
||||
# 4. Status pruefen:
|
||||
# https://gitea.meghsakha.com/Benjamin_Boenisch/breakpilot-core/actions
|
||||
```
|
||||
|
||||
### Coolify-deployed Services
|
||||
### Orca-deployed Services
|
||||
|
||||
| Service | Container | Beschreibung |
|
||||
|---------|-----------|--------------|
|
||||
@@ -91,7 +91,7 @@ git push gitea coolify
|
||||
| paddleocr-service | bp-core-paddleocr | OCR Engine (x86_64) |
|
||||
| health-aggregator | bp-core-health | Health-Check Aggregator |
|
||||
|
||||
## breakpilot-compliance → Coolify
|
||||
## breakpilot-compliance → Orca
|
||||
|
||||
### Pipeline
|
||||
|
||||
@@ -111,7 +111,7 @@ jobs:
|
||||
### Workflow
|
||||
|
||||
```bash
|
||||
# Committen und pushen → Coolify deployt automatisch:
|
||||
# Committen und pushen → Orca deployt automatisch:
|
||||
git push origin main && git push gitea main
|
||||
|
||||
# CI-Status pruefen:
|
||||
@@ -154,8 +154,8 @@ Workflows liegen in jedem Repo unter `.gitea/workflows/`:
|
||||
|
||||
| Repo | Workflow | Branch | Aktion |
|
||||
|------|----------|--------|--------|
|
||||
| breakpilot-core | `deploy-coolify.yml` | `coolify` | Coolify API Deploy |
|
||||
| breakpilot-compliance | `ci.yaml` | `main` | Tests + Coolify Deploy |
|
||||
| breakpilot-core | `deploy-orca.yml` | `orca` | Orca API Deploy |
|
||||
| breakpilot-compliance | `ci.yaml` | `main` | Tests + Orca Deploy |
|
||||
|
||||
### Runner-Token erneuern
|
||||
|
||||
@@ -181,7 +181,7 @@ ssh macmini "/usr/local/bin/docker logs -f bp-core-gitea-runner"
|
||||
|
||||
## Health Checks
|
||||
|
||||
### Production (Coolify)
|
||||
### Production (Orca)
|
||||
|
||||
```bash
|
||||
# Core PaddleOCR
|
||||
@@ -229,14 +229,14 @@ ssh macmini "docker compose build --no-cache <service>"
|
||||
|
||||
## Rollback
|
||||
|
||||
### Coolify
|
||||
### Orca
|
||||
|
||||
Ein Redeploy mit einem aelteren Commit kann durch Zuruecksetzen des Branches ausgeloest werden:
|
||||
|
||||
```bash
|
||||
# Branch auf vorherigen Commit zuruecksetzen und pushen
|
||||
git reset --hard <previous-commit>
|
||||
git push gitea coolify --force
|
||||
git push gitea orca --force
|
||||
```
|
||||
|
||||
### Lokal (Mac Mini)
|
||||
|
||||
+2
-2
@@ -16,8 +16,8 @@ BreakPilot besteht aus drei unabhaengigen Projekten:
|
||||
|
||||
| Repo | Deployment | Trigger |
|
||||
|------|-----------|---------|
|
||||
| **breakpilot-core** | Coolify (automatisch) | Push auf gitea main |
|
||||
| **breakpilot-compliance** | Coolify (automatisch) | Push auf gitea main |
|
||||
| **breakpilot-core** | Orca (automatisch) | Push auf gitea main |
|
||||
| **breakpilot-compliance** | Orca (automatisch) | Push auf gitea main |
|
||||
| **breakpilot-lehrer** | Mac Mini (lokal) | Manuell docker compose |
|
||||
|
||||
## Core Services
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Document Templates V2
|
||||
|
||||
Erweiterte Compliance-Vorlagen (DSFA, TOM, VVT, AVV) fuer den BreakPilot Document Generator.
|
||||
Erweiterte Compliance-Vorlagen (DSFA, TOM, VVT, AVV, BV, FRIA) fuer den BreakPilot Document Generator.
|
||||
|
||||
**Branch:** `feature/document-templates-v2`
|
||||
**Branch:** `feature/betriebsrat-compliance-module`
|
||||
**Ziel-Integration:** breakpilot-compliance (nach Abschluss des Refactoring)
|
||||
**Datenbank:** `compliance.compliance_legal_templates` (shared PostgreSQL)
|
||||
|
||||
@@ -17,14 +17,18 @@ Erweiterte Compliance-Vorlagen (DSFA, TOM, VVT, AVV) fuer den BreakPilot Documen
|
||||
| `003_vvt_sector_templates.sql` | VVT | 6 Branchen-Muster (IT/SaaS, Gesundheit, Handel, Handwerk, Bildung, Beratung) |
|
||||
| `004_avv_template.sql` | AVV | Auftragsverarbeitungsvertrag Art. 28, 12 Sections, TOM-Anlage |
|
||||
| `005_additional_templates.sql` | Div. | Verpflichtungserklaerung + Art. 13/14 Informationspflichten |
|
||||
| `006_betriebsvereinbarung_template.sql` | BV | Betriebsvereinbarung §87 BetrVG, 13 Sektionen (A-M), KI/IT-Systeme |
|
||||
| `007_fria_template.sql` | FRIA | Grundrechte-Folgenabschaetzung Art. 27 AI Act, 8 Sektionen |
|
||||
|
||||
### Python Generators (`generators/`)
|
||||
|
||||
| Datei | Beschreibung |
|
||||
|-------|--------------|
|
||||
| `dsfa_template.py` | DSFA-Generator mit Schwellwertanalyse, Bundesland-Mapping, SDM-TOM, Art. 36 |
|
||||
| `dsfa_template.py` | DSFA-Generator mit Schwellwertanalyse, Bundesland-Mapping, SDM-TOM, Art. 36, Domain-Risiken (HR/Edu/HC/Finance) |
|
||||
| `tom_template.py` | TOM-Generator mit SDM-Struktur, NIS2/ISO27001/AI Act Erweiterungen, Sektoren |
|
||||
| `vvt_template.py` | VVT-Generator mit 6 Branchen-Katalogen, Art. 30 Validierung |
|
||||
| `betriebsvereinbarung_template.py` | BV-Generator mit TOM-Befuellung, Konflikt-Score-basierte Schutzklauseln |
|
||||
| `fria_template.py` | FRIA-Generator mit Domain→Grundrechte-Mapping (6 Domains), Risikomatrix |
|
||||
|
||||
### Scripts (`scripts/`)
|
||||
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
"""Betriebsvereinbarung template generator — creates BV draft from UCCA assessment.
|
||||
|
||||
Generates a modular works council agreement (Betriebsvereinbarung) based on:
|
||||
- UCCA Assessment result (triggered rules, risk score, obligations)
|
||||
- Company profile (name, location, works council)
|
||||
- System details (name, type, modules)
|
||||
|
||||
Sections A-M follow the template in migration 006.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
# -- Default verbotene Nutzungen nach BAG-Rechtsprechung --------------------
|
||||
|
||||
DEFAULT_VERBOTENE_NUTZUNGEN = [
|
||||
"Verdeckte Leistungs- oder Verhaltenskontrolle einzelner Beschaeftigter",
|
||||
"Erstellung individueller Persoenlichkeitsprofile oder Verhaltensanalysen",
|
||||
"Nutzung von Nutzungshistorien zu disziplinarischen Zwecken",
|
||||
"Automatisierte Personalentscheidungen ohne menschliche Ueberpruefung (Art. 22 DSGVO)",
|
||||
"Personenbezogene Rankings oder Leistungsvergleiche ohne gesonderte Mitbestimmung",
|
||||
"Korrelation von Systemnutzungsdaten mit Leistungsbeurteilungen",
|
||||
]
|
||||
|
||||
AI_VERBOTENE_NUTZUNGEN = [
|
||||
"Einsatz von KI-Funktionen zur biometrischen Echtzeit-Identifizierung am Arbeitsplatz",
|
||||
"KI-gestuetztes Social Scoring von Beschaeftigten",
|
||||
"Nutzung von KI-generierten Bewertungen als alleinige Grundlage fuer Personalentscheidungen",
|
||||
]
|
||||
|
||||
# -- Standard-TOM Massnahmen ------------------------------------------------
|
||||
|
||||
DEFAULT_TOM = [
|
||||
"Rollen- und Rechtekonzept mit Least-Privilege-Prinzip",
|
||||
"Verschluesselung der Daten bei Uebertragung (TLS 1.2+) und Speicherung (AES-256)",
|
||||
"Protokollierung aller administrativen Zugriffe",
|
||||
"Pseudonymisierung personenbezogener Daten, wo technisch moeglich",
|
||||
"Deaktivierung nicht benoetigter Telemetrie- und Diagnosefunktionen",
|
||||
"Getrennte Umgebungen fuer Test und Produktion",
|
||||
"Regelmaessige Sicherheitsupdates und Patch-Management",
|
||||
"Zugangsschutz durch Multi-Faktor-Authentifizierung fuer Administratoren",
|
||||
]
|
||||
|
||||
# -- Standard erlaubte Reports ----------------------------------------------
|
||||
|
||||
DEFAULT_ERLAUBTE_REPORTS = [
|
||||
"Systemgesundheit und Verfuegbarkeit (ohne Personenbezug)",
|
||||
"Lizenznutzung auf aggregierter Ebene (Abteilung/Standort, nicht Person)",
|
||||
"Sicherheitsereignisse und Anomalien",
|
||||
"Speicherplatznutzung (ohne Personenbezug)",
|
||||
"Fehlerstatistiken (technisch, nicht personenbezogen)",
|
||||
]
|
||||
|
||||
# -- Standard Datenarten bei IT/KI-Systemen ---------------------------------
|
||||
|
||||
DATENARTEN_MAP = {
|
||||
"email": "E-Mail-Metadaten (Absender, Empfaenger, Zeitstempel — NICHT Inhalte)",
|
||||
"chat": "Chat-/Messaging-Metadaten (Teilnehmer, Zeitstempel)",
|
||||
"document": "Dokumentenmetadaten (Ersteller, Aenderungsdatum, Dateiname)",
|
||||
"login": "Anmeldedaten (Benutzername, Zeitstempel, IP-Adresse)",
|
||||
"usage": "Nutzungsdaten (aufgerufene Funktionen, Nutzungsdauer — aggregiert)",
|
||||
"prompt": "KI-Eingaben und -Ausgaben (Prompts, Antworten)",
|
||||
"calendar": "Kalendereintraege (Betreff, Teilnehmer, Zeiten)",
|
||||
"hr": "Personalstammdaten (Name, Abteilung, Position, Eintrittsdatum)",
|
||||
"performance": "Leistungsdaten (Kennzahlen, Bewertungen, Zielvereinbarungen)",
|
||||
"video": "Videoaufnahmen (Arbeitsplatz, Zugangsbereiche)",
|
||||
"location": "Standortdaten (GPS, WLAN-basierte Ortung, Gebaeudezutritt)",
|
||||
}
|
||||
|
||||
|
||||
def generate_betriebsvereinbarung_draft(ctx: dict) -> dict:
|
||||
"""Generate a Betriebsvereinbarung draft from company + assessment context.
|
||||
|
||||
Args:
|
||||
ctx: Dict with keys:
|
||||
Required:
|
||||
- company_name: str
|
||||
- system_name: str
|
||||
- system_description: str
|
||||
Optional:
|
||||
- company_address: str
|
||||
- employer_representative: str
|
||||
- works_council_chair: str
|
||||
- system_vendor: str
|
||||
- locations: list[str]
|
||||
- departments: list[str]
|
||||
- modules: list[str]
|
||||
- purposes: list[str]
|
||||
- data_types: list[str] — keys from DATENARTEN_MAP
|
||||
- is_ai_system: bool
|
||||
- has_employee_monitoring: bool
|
||||
- has_hr_features: bool
|
||||
- has_video: bool
|
||||
- dpo_name: str
|
||||
- dpo_contact: str
|
||||
- audit_interval: str — e.g. "12 Monate"
|
||||
- duration: str — e.g. "unbefristet"
|
||||
- notice_period: str — e.g. "3 Monate"
|
||||
- retention_audit_logs: str — e.g. "90 Tage"
|
||||
- retention_usage_data: str — e.g. "30 Tage"
|
||||
- retention_prompts: str — e.g. "deaktiviert"
|
||||
- additional_forbidden: list[str]
|
||||
- additional_tom: list[str]
|
||||
- additional_reports: list[str]
|
||||
- betrvg_conflict_score: int — 0-100
|
||||
|
||||
Returns:
|
||||
Dict with placeholder values ready for template substitution.
|
||||
"""
|
||||
result = {}
|
||||
|
||||
# Basic info
|
||||
result["UNTERNEHMEN_NAME"] = ctx.get("company_name", "{{UNTERNEHMEN_NAME}}")
|
||||
result["UNTERNEHMEN_SITZ"] = ctx.get("company_address", "{{UNTERNEHMEN_SITZ}}")
|
||||
result["ARBEITGEBER_VERTRETER"] = ctx.get("employer_representative", "{{ARBEITGEBER_VERTRETER}}")
|
||||
result["BETRIEBSRAT_VORSITZ"] = ctx.get("works_council_chair", "{{BETRIEBSRAT_VORSITZ}}")
|
||||
result["SYSTEM_NAME"] = ctx.get("system_name", "{{SYSTEM_NAME}}")
|
||||
result["SYSTEM_BESCHREIBUNG"] = ctx.get("system_description", "{{SYSTEM_BESCHREIBUNG}}")
|
||||
result["SYSTEM_HERSTELLER"] = ctx.get("system_vendor", "")
|
||||
result["DSB_NAME"] = ctx.get("dpo_name", "{{DSB_NAME}}")
|
||||
result["DSB_KONTAKT"] = ctx.get("dpo_contact", "{{DSB_KONTAKT}}")
|
||||
|
||||
# B. Geltungsbereich
|
||||
locations = ctx.get("locations", [])
|
||||
result["GELTUNGSBEREICH_STANDORTE"] = _bullet_list(locations) if locations else "Alle Standorte der {{UNTERNEHMEN_NAME}}"
|
||||
|
||||
departments = ctx.get("departments", [])
|
||||
result["GELTUNGSBEREICH_BEREICHE"] = _bullet_list(departments) if departments else "Alle Beschaeftigten"
|
||||
|
||||
modules = ctx.get("modules", [])
|
||||
result["GELTUNGSBEREICH_MODULE"] = _bullet_list(modules) if modules else "Alle Module und Dienste von {{SYSTEM_NAME}}"
|
||||
|
||||
# C. Zweck
|
||||
purposes = ctx.get("purposes", [])
|
||||
result["ZWECK_BESCHREIBUNG"] = _bullet_list(purposes) if purposes else "{{ZWECK_BESCHREIBUNG}}"
|
||||
|
||||
# C.2 Verbotene Nutzungen
|
||||
forbidden = list(DEFAULT_VERBOTENE_NUTZUNGEN)
|
||||
if ctx.get("is_ai_system"):
|
||||
forbidden.extend(AI_VERBOTENE_NUTZUNGEN)
|
||||
forbidden.extend(ctx.get("additional_forbidden", []))
|
||||
result["VERBOTENE_NUTZUNGEN"] = _bullet_list(forbidden)
|
||||
|
||||
# D. Datenarten
|
||||
data_type_keys = ctx.get("data_types", [])
|
||||
datenarten = []
|
||||
for key in data_type_keys:
|
||||
if key in DATENARTEN_MAP:
|
||||
datenarten.append(DATENARTEN_MAP[key])
|
||||
else:
|
||||
datenarten.append(key)
|
||||
result["DATENARTEN_LISTE"] = _bullet_list(datenarten) if datenarten else "{{DATENARTEN_LISTE}}"
|
||||
|
||||
# E. Rollen
|
||||
result["ROLLEN_ADMIN"] = ctx.get("roles_admin", "IT-Administration: Systemkonfiguration, Benutzerverwaltung, Sicherheitsupdates")
|
||||
result["ROLLEN_FUEHRUNGSKRAFT"] = ctx.get("roles_manager", "Fuehrungskraefte: Nur aggregierte, nicht-personenbezogene Reports")
|
||||
result["ROLLEN_REPORTING"] = ctx.get("roles_reporting", "Controlling/Reporting: Nur freigegebene Standardreports (siehe Abschnitt G)")
|
||||
|
||||
# F. Transparenz
|
||||
result["TRANSPARENZ_INFO"] = ctx.get("transparency_info",
|
||||
"Die Information erfolgt schriftlich und in einer Informationsveranstaltung vor Einfuehrung des Systems.")
|
||||
|
||||
# G. Reports
|
||||
reports = list(DEFAULT_ERLAUBTE_REPORTS)
|
||||
reports.extend(ctx.get("additional_reports", []))
|
||||
result["ERLAUBTE_REPORTS"] = _bullet_list(reports)
|
||||
|
||||
# H. Speicherfristen
|
||||
result["SPEICHERFRIST_AUDIT_LOGS"] = ctx.get("retention_audit_logs", "90 Tage")
|
||||
result["SPEICHERFRIST_NUTZUNGSDATEN"] = ctx.get("retention_usage_data", "30 Tage")
|
||||
result["SPEICHERFRIST_CHAT_PROMPTS"] = ctx.get("retention_prompts", "deaktiviert")
|
||||
|
||||
# I. TOM
|
||||
tom = list(DEFAULT_TOM)
|
||||
tom.extend(ctx.get("additional_tom", []))
|
||||
# Intensivere Schutzmassnahmen bei hohem Konflikt-Score
|
||||
conflict_score = ctx.get("betrvg_conflict_score", 0)
|
||||
if conflict_score >= 50:
|
||||
tom.append("Automatische Anomalie-Erkennung bei ungewoehnlichen Admin-Zugriffen")
|
||||
tom.append("Quartalsweise Datenschutz-Audit durch externen Prueer")
|
||||
if conflict_score >= 75:
|
||||
tom.append("Betriebsrat erhaelt Leserechte auf Audit-Log-Dashboard")
|
||||
tom.append("Jede Sonderauswertung wird dem Betriebsrat innerhalb von 24h gemeldet")
|
||||
result["TOM_MASSNAHMEN"] = _bullet_list(tom)
|
||||
|
||||
# J. Change-Management
|
||||
result["CHANGE_MANAGEMENT_PROZESS"] = ctx.get("change_process",
|
||||
"Die Arbeitgeberin informiert den Betriebsrat schriftlich ueber geplante Aenderungen "
|
||||
"mindestens 14 Kalendertage vor Umsetzung. Bei sicherheitskritischen Updates kann die "
|
||||
"Frist auf 3 Werktage verkuerzt werden.")
|
||||
|
||||
# K. Audit
|
||||
result["AUDIT_INTERVALL"] = ctx.get("audit_interval", "12 Monate")
|
||||
|
||||
# L. Beschwerde
|
||||
result["BESCHWERDE_ANSPRECHPARTNER"] = ctx.get("complaint_contacts",
|
||||
"- Direkter Vorgesetzter\n- Betriebsrat ({{BETRIEBSRAT_VORSITZ}})\n"
|
||||
"- Datenschutzbeauftragter ({{DSB_NAME}}, {{DSB_KONTAKT}})")
|
||||
|
||||
# M. Schluss
|
||||
result["LAUFZEIT"] = ctx.get("duration", "unbefristet")
|
||||
result["KUENDIGUNGSFRIST"] = ctx.get("notice_period", "3 Monate")
|
||||
result["DATUM_UNTERZEICHNUNG"] = ctx.get("signing_date", "{{DATUM_UNTERZEICHNUNG}}")
|
||||
|
||||
# Conditional flags
|
||||
result["AI_SYSTEM"] = ctx.get("is_ai_system", False)
|
||||
result["VIDEO_UEBERWACHUNG"] = ctx.get("has_video", False)
|
||||
result["HR_SYSTEM"] = ctx.get("has_hr_features", False)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _bullet_list(items: list) -> str:
|
||||
"""Format a list as markdown bullet points."""
|
||||
return "\n".join(f"- {item}" for item in items)
|
||||
@@ -330,6 +330,36 @@ def _generate_risk_assessment(ctx: dict) -> str:
|
||||
if any(ctx.get(k) for k in ["third_country_transfer", "processes_in_third_country"]):
|
||||
risks.append(("Zugriff durch Behoerden in Drittlaendern", "mittel", "hoch", "hoch"))
|
||||
|
||||
# FISA 702 Risiko bei US-Cloud-Providern
|
||||
hosting = (ctx.get("hosting_provider") or "").lower()
|
||||
us_providers = ("aws", "azure", "google", "microsoft", "amazon", "openai", "anthropic", "oracle")
|
||||
if any(p in hosting for p in us_providers):
|
||||
risks.append(("FISA 702: Zugriff durch US-Behoerden auf EU-Daten nicht ausschliessbar", "mittel", "hoch", "hoch"))
|
||||
risks.append(("EU-Serverstandort schuetzt nicht gegen US-Rechtszugriff (Cloud Act + FISA)", "mittel", "hoch", "hoch"))
|
||||
risks.append(("Fehlende effektive Rechtsbehelfe fuer EU-Betroffene gegen US-Ueberwachung", "mittel", "hoch", "hoch"))
|
||||
|
||||
# Domain-spezifische Risiken (AI Act Annex III)
|
||||
domain = ctx.get("domain", "")
|
||||
if domain in ("hr", "recruiting") or ctx.get("has_hr_context"):
|
||||
risks.append(("AGG-Verstoss: Diskriminierung bei Bewerberauswahl (§ 1 AGG)", "mittel", "hoch", "hoch"))
|
||||
risks.append(("Beweislastumkehr bei Diskriminierungsklagen (§ 22 AGG)", "mittel", "hoch", "hoch"))
|
||||
risks.append(("Art. 22 DSGVO: Unzulaessige automatisierte Einzelentscheidung", "mittel", "hoch", "hoch"))
|
||||
risks.append(("Proxy-Diskriminierung durch Name/Foto/Alter-Erkennung", "mittel", "hoch", "hoch"))
|
||||
|
||||
if domain in ("education", "higher_education", "vocational_training"):
|
||||
risks.append(("Chancenungleichheit durch KI-gestuetzte Bewertung", "mittel", "hoch", "hoch"))
|
||||
risks.append(("Benachteiligung Minderjaehriger ohne Lehrkraft-Kontrolle", "niedrig", "gross", "hoch"))
|
||||
risks.append(("Fehlbewertung mit Auswirkung auf Bildungschancen", "mittel", "hoch", "hoch"))
|
||||
|
||||
if domain in ("healthcare", "medical_devices", "pharma", "elderly_care"):
|
||||
risks.append(("Fehldiagnose durch KI mit gesundheitlichen Folgen", "niedrig", "gross", "hoch"))
|
||||
risks.append(("Falsche Triage-Priorisierung (lebenskritisch)", "niedrig", "gross", "hoch"))
|
||||
risks.append(("Verletzung der Patientenautonomie", "mittel", "hoch", "hoch"))
|
||||
|
||||
if domain in ("finance", "banking", "insurance", "investment"):
|
||||
risks.append(("Diskriminierendes Kredit-Scoring", "mittel", "hoch", "hoch"))
|
||||
risks.append(("Ungerechtfertigte Verweigerung von Finanzdienstleistungen", "mittel", "hoch", "hoch"))
|
||||
|
||||
lines.append("| Risiko | Eintrittswahrscheinlichkeit | Schwere | Gesamt |")
|
||||
lines.append("|--------|----------------------------|---------|--------|")
|
||||
for risk_name, likelihood, severity, overall in risks:
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
"""FRIA template generator — creates Fundamental Rights Impact Assessment from UCCA context.
|
||||
|
||||
Generates a FRIA (Art. 27 AI Act) based on:
|
||||
- UCCA Assessment result (risk level, triggered rules, domain)
|
||||
- AI Act Decision Tree classification
|
||||
- Company profile
|
||||
|
||||
Automatically maps domains to affected fundamental rights.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
# -- Domain → Fundamental Rights Mapping ------------------------------------
|
||||
|
||||
DOMAIN_RIGHTS_MAP = {
|
||||
"education": [
|
||||
{"right": "Recht auf Bildung", "charter": "Art. 14", "gg": "Art. 12",
|
||||
"risk": "Chancengleichheit bei KI-gestuetzter Bewertung oder Auswahl"},
|
||||
{"right": "Nicht-Diskriminierung", "charter": "Art. 21", "gg": "Art. 3",
|
||||
"risk": "Bias bei Leistungsbewertung nach Herkunft, Sprache oder Geschlecht"},
|
||||
{"right": "Rechte des Kindes", "charter": "Art. 24", "gg": "Art. 6 Abs. 2",
|
||||
"risk": "Besonderer Schutz Minderjaehriger vor automatisierten Bewertungen"},
|
||||
],
|
||||
"hr": [
|
||||
{"right": "Berufsfreiheit / Recht zu arbeiten", "charter": "Art. 15", "gg": "Art. 12",
|
||||
"risk": "KI-gestuetzte Auswahl kann Zugang zum Arbeitsmarkt einschraenken"},
|
||||
{"right": "Nicht-Diskriminierung", "charter": "Art. 21", "gg": "Art. 3",
|
||||
"risk": "Bias bei Recruiting, Befoerderung oder Kuendigung"},
|
||||
{"right": "Schutz personenbezogener Daten", "charter": "Art. 8", "gg": "Art. 2 Abs. 1",
|
||||
"risk": "Umfangreiche Verarbeitung von Beschaeftigtendaten"},
|
||||
],
|
||||
"healthcare": [
|
||||
{"right": "Menschenwuerde", "charter": "Art. 1", "gg": "Art. 1",
|
||||
"risk": "KI-Diagnosen koennen existenzielle Auswirkungen haben"},
|
||||
{"right": "Schutz personenbezogener Daten", "charter": "Art. 8", "gg": "Art. 2 Abs. 1",
|
||||
"risk": "Gesundheitsdaten sind besondere Kategorien (Art. 9 DSGVO)"},
|
||||
{"right": "Nicht-Diskriminierung", "charter": "Art. 21", "gg": "Art. 3",
|
||||
"risk": "Bias bei Behandlungsempfehlungen nach Alter, Geschlecht oder Ethnie"},
|
||||
],
|
||||
"finance": [
|
||||
{"right": "Recht auf soziale Sicherheit", "charter": "Art. 34", "gg": "Art. 20",
|
||||
"risk": "Zugang zu Finanzdienstleistungen und Versicherungen"},
|
||||
{"right": "Nicht-Diskriminierung", "charter": "Art. 21", "gg": "Art. 3",
|
||||
"risk": "Scoring-Bias bei Kreditvergabe oder Versicherungspraemien"},
|
||||
{"right": "Recht auf wirksamen Rechtsbehelf", "charter": "Art. 47", "gg": "Art. 19 Abs. 4",
|
||||
"risk": "Anfechtbarkeit automatisierter Finanzentscheidungen"},
|
||||
],
|
||||
"law_enforcement": [
|
||||
{"right": "Recht auf Freiheit und Sicherheit", "charter": "Art. 6", "gg": "Art. 2 Abs. 2",
|
||||
"risk": "KI-gestuetzte Ueberwachung oder Vorhersage"},
|
||||
{"right": "Unschuldsvermutung", "charter": "Art. 48", "gg": "Art. 20 Abs. 3",
|
||||
"risk": "Predictive Policing kann Vorverurteilung erzeugen"},
|
||||
{"right": "Recht auf Privatsphaere", "charter": "Art. 7", "gg": "Art. 2 Abs. 1",
|
||||
"risk": "Biometrische Identifizierung im oeffentlichen Raum"},
|
||||
],
|
||||
"public_sector": [
|
||||
{"right": "Recht auf eine gute Verwaltung", "charter": "Art. 41", "gg": "Art. 20 Abs. 3",
|
||||
"risk": "Automatisierte Verwaltungsentscheidungen muessen nachvollziehbar sein"},
|
||||
{"right": "Nicht-Diskriminierung", "charter": "Art. 21", "gg": "Art. 3",
|
||||
"risk": "Gleichbehandlung aller Buerger bei KI-gestuetzten Verwaltungsakten"},
|
||||
{"right": "Recht auf wirksamen Rechtsbehelf", "charter": "Art. 47", "gg": "Art. 19 Abs. 4",
|
||||
"risk": "Widerspruchsmoeglichkeit gegen KI-gestuetzte Bescheide"},
|
||||
],
|
||||
}
|
||||
|
||||
# Universal rights (always relevant for High-Risk AI)
|
||||
UNIVERSAL_RIGHTS = [
|
||||
{"right": "Schutz personenbezogener Daten", "charter": "Art. 8", "gg": "Art. 2 Abs. 1 i.V.m. Art. 1 Abs. 1",
|
||||
"risk": "Datenverarbeitung durch KI-System"},
|
||||
{"right": "Menschenwuerde", "charter": "Art. 1", "gg": "Art. 1",
|
||||
"risk": "KI darf Menschen nicht auf Datenpunkte reduzieren"},
|
||||
]
|
||||
|
||||
# -- Default measures -------------------------------------------------------
|
||||
|
||||
DEFAULT_MEASURES = [
|
||||
"Human-in-the-Loop: Menschliche Ueberpruefung aller KI-Empfehlungen vor Umsetzung",
|
||||
"Transparenz: Betroffene werden ueber den Einsatz von KI informiert",
|
||||
"Erklaerbarkeit: KI-Ergebnisse koennen nachvollzogen und begruendet werden",
|
||||
"Beschwerdemechanismus: Betroffene koennen KI-Entscheidungen anfechten",
|
||||
"Logging: Alle Eingaben und Ausgaben werden fuer Audit-Zwecke protokolliert",
|
||||
"Regelmaessige Bias-Audits: Systematische Pruefung auf Diskriminierung",
|
||||
]
|
||||
|
||||
HR_MEASURES = [
|
||||
"AGG-konforme Gestaltung: Kein Bias bei Geschlecht, Alter, Herkunft, Behinderung",
|
||||
"Betriebsrat gemaess §87 Abs.1 Nr.6 und §95 BetrVG beteiligt",
|
||||
"Keine automatisierte Endentscheidung bei Personalangelegenheiten",
|
||||
]
|
||||
|
||||
EDUCATION_MEASURES = [
|
||||
"Lehrkraft ueberprueft und verantwortet alle KI-generierten Bewertungen",
|
||||
"Chancengleichheit unabhaengig von sozioekonomischem Hintergrund",
|
||||
"Schueler/Eltern koennen KI-gestuetzte Bewertungen anfechten",
|
||||
]
|
||||
|
||||
|
||||
def generate_fria_draft(ctx: dict) -> dict:
|
||||
"""Generate a FRIA draft from UCCA assessment context.
|
||||
|
||||
Args:
|
||||
ctx: Dict with keys:
|
||||
Required:
|
||||
- organisation_name: str
|
||||
- system_name: str
|
||||
- system_description: str
|
||||
- einsatzzweck: str
|
||||
Optional:
|
||||
- organisation_address: str
|
||||
- system_version: str
|
||||
- system_provider: str
|
||||
- domain: str (education, hr, healthcare, finance, etc.)
|
||||
- affected_groups: list[str]
|
||||
- affected_count: str
|
||||
- ai_act_classification: str (high_risk, limited_risk, etc.)
|
||||
- annex_iii_category: str
|
||||
- is_public_entity: bool
|
||||
- has_hr_context: bool
|
||||
- has_education_context: bool
|
||||
- risk_score: int
|
||||
- dpo_name: str
|
||||
- dpo_contact: str
|
||||
- review_interval: str
|
||||
|
||||
Returns:
|
||||
Dict with placeholder values for template substitution.
|
||||
"""
|
||||
result = {}
|
||||
|
||||
# Section 1: Basic info
|
||||
result["ORGANISATION_NAME"] = ctx.get("organisation_name", "{{ORGANISATION_NAME}}")
|
||||
result["ORGANISATION_ADRESSE"] = ctx.get("organisation_address", "{{ORGANISATION_ADRESSE}}")
|
||||
result["VERANTWORTLICHER"] = ctx.get("responsible_person", "{{VERANTWORTLICHER}}")
|
||||
result["ERSTELLT_VON"] = ctx.get("created_by", "{{ERSTELLT_VON}}")
|
||||
result["ERSTELLT_AM"] = ctx.get("created_at", "{{ERSTELLT_AM}}")
|
||||
result["SYSTEM_NAME"] = ctx.get("system_name", "{{SYSTEM_NAME}}")
|
||||
result["SYSTEM_VERSION"] = ctx.get("system_version", "1.0")
|
||||
result["SYSTEM_BESCHREIBUNG"] = ctx.get("system_description", "{{SYSTEM_BESCHREIBUNG}}")
|
||||
result["SYSTEM_ANBIETER"] = ctx.get("system_provider", "{{SYSTEM_ANBIETER}}")
|
||||
result["EINSATZZWECK"] = ctx.get("einsatzzweck", "{{EINSATZZWECK}}")
|
||||
result["EINSATZKONTEXT"] = ctx.get("einsatzkontext", "{{EINSATZKONTEXT}}")
|
||||
result["AI_ACT_KLASSIFIKATION"] = ctx.get("ai_act_classification", "High-Risk")
|
||||
result["ANNEX_III_KATEGORIE"] = ctx.get("annex_iii_category", "")
|
||||
result["DSB_NAME"] = ctx.get("dpo_name", "{{DSB_NAME}}")
|
||||
result["DSB_KONTAKT"] = ctx.get("dpo_contact", "{{DSB_KONTAKT}}")
|
||||
|
||||
# Section 1.5: Affected groups
|
||||
groups = ctx.get("affected_groups", [])
|
||||
result["BETROFFENE_GRUPPEN"] = _bullet_list(groups) if groups else "{{BETROFFENE_GRUPPEN}}"
|
||||
result["BETROFFENE_ANZAHL"] = ctx.get("affected_count", "{{BETROFFENE_ANZAHL}}")
|
||||
|
||||
# Section 2: Fundamental rights mapping
|
||||
domain = ctx.get("domain", "")
|
||||
rights = list(UNIVERSAL_RIGHTS)
|
||||
if domain in DOMAIN_RIGHTS_MAP:
|
||||
rights.extend(DOMAIN_RIGHTS_MAP[domain])
|
||||
|
||||
rights_table = []
|
||||
for i, r in enumerate(rights, 1):
|
||||
rights_table.append(
|
||||
f"| {i} | {r['right']} | {r['charter']} | {r['gg']} | Ja | {r['risk']} |"
|
||||
)
|
||||
result["GRUNDRECHTE_ANALYSE"] = "\n".join(rights_table) if rights_table else "{{GRUNDRECHTE_ANALYSE}}"
|
||||
|
||||
# Section 3: Risk matrix
|
||||
risk_rows = []
|
||||
risk_score = ctx.get("risk_score", 0)
|
||||
base_likelihood = min(3, 1 + risk_score // 30)
|
||||
for r in rights:
|
||||
severity = 3 if "Diskriminierung" in r["risk"] or "existenz" in r["risk"].lower() else 2
|
||||
likelihood = base_likelihood
|
||||
level = _risk_level(likelihood * severity)
|
||||
risk_rows.append(
|
||||
f"| {r['right']} | {r['risk']} | {likelihood} | {severity} | {level} | Basierend auf Systemanalyse |"
|
||||
)
|
||||
result["RISIKOMATRIX"] = "\n".join(risk_rows) if risk_rows else "{{RISIKOMATRIX}}"
|
||||
|
||||
# Section 4: Measures
|
||||
measures = list(DEFAULT_MEASURES)
|
||||
if ctx.get("has_hr_context") or domain == "hr":
|
||||
measures.extend(HR_MEASURES)
|
||||
if ctx.get("has_education_context") or domain == "education":
|
||||
measures.extend(EDUCATION_MEASURES)
|
||||
result["MASSNAHMEN_LISTE"] = _bullet_list(measures)
|
||||
|
||||
result["HUMAN_OVERSIGHT_BESCHREIBUNG"] = ctx.get("human_oversight",
|
||||
"Das System unterstuetzt menschliche Entscheidungen, trifft jedoch keine eigenstaendigen Entscheidungen. "
|
||||
"Alle KI-generierten Empfehlungen werden von qualifiziertem Personal geprueft.")
|
||||
|
||||
result["TRANSPARENZ_MASSNAHMEN"] = ctx.get("transparency_measures",
|
||||
"Betroffene Personen werden ueber den Einsatz des KI-Systems informiert. "
|
||||
"KI-generierte Ergebnisse werden als solche gekennzeichnet.")
|
||||
|
||||
# Section 5: Consultation
|
||||
result["KONSULTATION_ERGEBNISSE"] = ctx.get("consultation_results",
|
||||
"Konsultation steht aus — bitte vor Freigabe durchfuehren.")
|
||||
|
||||
# Section 6: Approval
|
||||
result["GENEHMIGT_VON"] = ctx.get("approved_by", "{{GENEHMIGT_VON}}")
|
||||
result["GENEHMIGT_AM"] = ctx.get("approved_at", "{{GENEHMIGT_AM}}")
|
||||
|
||||
# Section 7: Monitoring
|
||||
result["NAECHSTE_UEBERPRUEFUNG"] = ctx.get("review_interval", "12 Monate nach Inbetriebnahme")
|
||||
|
||||
# Conditional flags
|
||||
result["BILDUNGSKONTEXT"] = ctx.get("has_education_context", False) or domain == "education"
|
||||
result["HR_KONTEXT"] = ctx.get("has_hr_context", False) or domain == "hr"
|
||||
result["OEFFENTLICHE_STELLE"] = ctx.get("is_public_entity", False)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _risk_level(score: int) -> str:
|
||||
"""Map risk score to level label."""
|
||||
if score <= 6:
|
||||
return "Niedrig"
|
||||
elif score <= 12:
|
||||
return "Mittel"
|
||||
elif score <= 19:
|
||||
return "Hoch"
|
||||
else:
|
||||
return "Kritisch"
|
||||
|
||||
|
||||
def _bullet_list(items: list) -> str:
|
||||
"""Format a list as markdown bullet points."""
|
||||
return "\n".join(f"- {item}" for item in items)
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Tests for Betriebsvereinbarung template generator."""
|
||||
|
||||
import pytest
|
||||
from betriebsvereinbarung_template import (
|
||||
generate_betriebsvereinbarung_draft,
|
||||
DEFAULT_VERBOTENE_NUTZUNGEN,
|
||||
AI_VERBOTENE_NUTZUNGEN,
|
||||
DEFAULT_TOM,
|
||||
DATENARTEN_MAP,
|
||||
)
|
||||
|
||||
|
||||
class TestGenerateBetriebsvereinbarung:
|
||||
"""Tests for generate_betriebsvereinbarung_draft()."""
|
||||
|
||||
def test_minimal_context(self):
|
||||
"""Minimal context should produce valid output with placeholders."""
|
||||
ctx = {
|
||||
"company_name": "Test GmbH",
|
||||
"system_name": "Microsoft 365",
|
||||
"system_description": "Office-Suite mit KI-Funktionen",
|
||||
}
|
||||
result = generate_betriebsvereinbarung_draft(ctx)
|
||||
|
||||
assert result["UNTERNEHMEN_NAME"] == "Test GmbH"
|
||||
assert result["SYSTEM_NAME"] == "Microsoft 365"
|
||||
assert "{{BETRIEBSRAT_VORSITZ}}" in result["BETRIEBSRAT_VORSITZ"]
|
||||
|
||||
def test_full_context(self):
|
||||
"""Full context should fill all placeholders."""
|
||||
ctx = {
|
||||
"company_name": "Acme Corp",
|
||||
"company_address": "Hamburg",
|
||||
"employer_representative": "Dr. Schmidt",
|
||||
"works_council_chair": "Fr. Mueller",
|
||||
"system_name": "Copilot",
|
||||
"system_description": "KI-Assistent",
|
||||
"system_vendor": "Microsoft",
|
||||
"locations": ["Hamburg", "Berlin"],
|
||||
"departments": ["IT", "HR"],
|
||||
"modules": ["Teams", "Outlook", "Word"],
|
||||
"purposes": ["Texterstellung", "Zusammenfassung"],
|
||||
"data_types": ["email", "chat", "login"],
|
||||
"is_ai_system": True,
|
||||
"dpo_name": "Dr. Datenschutz",
|
||||
"dpo_contact": "dsb@acme.de",
|
||||
"audit_interval": "6 Monate",
|
||||
"duration": "2 Jahre",
|
||||
"notice_period": "6 Monate",
|
||||
}
|
||||
result = generate_betriebsvereinbarung_draft(ctx)
|
||||
|
||||
assert result["ARBEITGEBER_VERTRETER"] == "Dr. Schmidt"
|
||||
assert result["BETRIEBSRAT_VORSITZ"] == "Fr. Mueller"
|
||||
assert "Hamburg" in result["GELTUNGSBEREICH_STANDORTE"]
|
||||
assert "Berlin" in result["GELTUNGSBEREICH_STANDORTE"]
|
||||
assert "Teams" in result["GELTUNGSBEREICH_MODULE"]
|
||||
assert result["AUDIT_INTERVALL"] == "6 Monate"
|
||||
assert result["LAUFZEIT"] == "2 Jahre"
|
||||
assert result["AI_SYSTEM"] is True
|
||||
|
||||
def test_verbotene_nutzungen_default(self):
|
||||
"""Default forbidden uses should always be included."""
|
||||
ctx = {"company_name": "Test", "system_name": "Tool", "system_description": "x"}
|
||||
result = generate_betriebsvereinbarung_draft(ctx)
|
||||
|
||||
for nutzung in DEFAULT_VERBOTENE_NUTZUNGEN:
|
||||
assert nutzung in result["VERBOTENE_NUTZUNGEN"]
|
||||
|
||||
def test_verbotene_nutzungen_ai_system(self):
|
||||
"""AI-specific forbidden uses should be added for AI systems."""
|
||||
ctx = {
|
||||
"company_name": "Test",
|
||||
"system_name": "Tool",
|
||||
"system_description": "x",
|
||||
"is_ai_system": True,
|
||||
}
|
||||
result = generate_betriebsvereinbarung_draft(ctx)
|
||||
|
||||
for nutzung in AI_VERBOTENE_NUTZUNGEN:
|
||||
assert nutzung in result["VERBOTENE_NUTZUNGEN"]
|
||||
|
||||
def test_verbotene_nutzungen_no_ai(self):
|
||||
"""AI-specific forbidden uses should NOT be added for non-AI systems."""
|
||||
ctx = {
|
||||
"company_name": "Test",
|
||||
"system_name": "Tool",
|
||||
"system_description": "x",
|
||||
"is_ai_system": False,
|
||||
}
|
||||
result = generate_betriebsvereinbarung_draft(ctx)
|
||||
|
||||
for nutzung in AI_VERBOTENE_NUTZUNGEN:
|
||||
assert nutzung not in result["VERBOTENE_NUTZUNGEN"]
|
||||
|
||||
def test_datenarten_mapping(self):
|
||||
"""Data types should be resolved from DATENARTEN_MAP."""
|
||||
ctx = {
|
||||
"company_name": "Test",
|
||||
"system_name": "Tool",
|
||||
"system_description": "x",
|
||||
"data_types": ["email", "prompt", "hr"],
|
||||
}
|
||||
result = generate_betriebsvereinbarung_draft(ctx)
|
||||
|
||||
assert DATENARTEN_MAP["email"] in result["DATENARTEN_LISTE"]
|
||||
assert DATENARTEN_MAP["prompt"] in result["DATENARTEN_LISTE"]
|
||||
assert DATENARTEN_MAP["hr"] in result["DATENARTEN_LISTE"]
|
||||
|
||||
def test_tom_high_conflict_score(self):
|
||||
"""High conflict score should add extra TOM measures."""
|
||||
ctx_low = {
|
||||
"company_name": "Test",
|
||||
"system_name": "Tool",
|
||||
"system_description": "x",
|
||||
"betrvg_conflict_score": 20,
|
||||
}
|
||||
ctx_high = {
|
||||
"company_name": "Test",
|
||||
"system_name": "Tool",
|
||||
"system_description": "x",
|
||||
"betrvg_conflict_score": 80,
|
||||
}
|
||||
|
||||
result_low = generate_betriebsvereinbarung_draft(ctx_low)
|
||||
result_high = generate_betriebsvereinbarung_draft(ctx_high)
|
||||
|
||||
# High score should have more TOM items
|
||||
low_count = result_low["TOM_MASSNAHMEN"].count("- ")
|
||||
high_count = result_high["TOM_MASSNAHMEN"].count("- ")
|
||||
assert high_count > low_count, f"High conflict ({high_count} TOMs) should have more than low ({low_count})"
|
||||
|
||||
def test_speicherfristen_defaults(self):
|
||||
"""Default retention periods should be set."""
|
||||
ctx = {"company_name": "Test", "system_name": "Tool", "system_description": "x"}
|
||||
result = generate_betriebsvereinbarung_draft(ctx)
|
||||
|
||||
assert result["SPEICHERFRIST_AUDIT_LOGS"] == "90 Tage"
|
||||
assert result["SPEICHERFRIST_NUTZUNGSDATEN"] == "30 Tage"
|
||||
assert result["SPEICHERFRIST_CHAT_PROMPTS"] == "deaktiviert"
|
||||
|
||||
def test_custom_retention(self):
|
||||
"""Custom retention periods should override defaults."""
|
||||
ctx = {
|
||||
"company_name": "Test",
|
||||
"system_name": "Tool",
|
||||
"system_description": "x",
|
||||
"retention_audit_logs": "180 Tage",
|
||||
"retention_prompts": "7 Tage",
|
||||
}
|
||||
result = generate_betriebsvereinbarung_draft(ctx)
|
||||
|
||||
assert result["SPEICHERFRIST_AUDIT_LOGS"] == "180 Tage"
|
||||
assert result["SPEICHERFRIST_CHAT_PROMPTS"] == "7 Tage"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,198 @@
|
||||
"""Tests for FRIA (Fundamental Rights Impact Assessment) template generator."""
|
||||
|
||||
import pytest
|
||||
from fria_template import (
|
||||
generate_fria_draft,
|
||||
DOMAIN_RIGHTS_MAP,
|
||||
UNIVERSAL_RIGHTS,
|
||||
DEFAULT_MEASURES,
|
||||
HR_MEASURES,
|
||||
EDUCATION_MEASURES,
|
||||
)
|
||||
|
||||
|
||||
class TestGenerateFRIA:
|
||||
"""Tests for generate_fria_draft()."""
|
||||
|
||||
def test_minimal_context(self):
|
||||
ctx = {
|
||||
"organisation_name": "Test GmbH",
|
||||
"system_name": "AI Tool",
|
||||
"system_description": "KI-Assistenz",
|
||||
"einsatzzweck": "Automatisierung",
|
||||
}
|
||||
result = generate_fria_draft(ctx)
|
||||
assert result["ORGANISATION_NAME"] == "Test GmbH"
|
||||
assert result["SYSTEM_NAME"] == "AI Tool"
|
||||
assert result["AI_ACT_KLASSIFIKATION"] == "High-Risk"
|
||||
|
||||
def test_hr_domain_rights(self):
|
||||
ctx = {
|
||||
"organisation_name": "HR Corp",
|
||||
"system_name": "Recruiting AI",
|
||||
"system_description": "Bewerber-Screening",
|
||||
"einsatzzweck": "Personalauswahl",
|
||||
"domain": "hr",
|
||||
}
|
||||
result = generate_fria_draft(ctx)
|
||||
|
||||
# HR domain should include employment rights
|
||||
assert "Berufsfreiheit" in result["GRUNDRECHTE_ANALYSE"]
|
||||
assert "Nicht-Diskriminierung" in result["GRUNDRECHTE_ANALYSE"]
|
||||
assert result["HR_KONTEXT"] is True
|
||||
assert result["BILDUNGSKONTEXT"] is False
|
||||
|
||||
def test_education_domain_rights(self):
|
||||
ctx = {
|
||||
"organisation_name": "Schule",
|
||||
"system_name": "Bewertungs-KI",
|
||||
"system_description": "Notenunterstuetzung",
|
||||
"einsatzzweck": "Leistungsbewertung",
|
||||
"domain": "education",
|
||||
}
|
||||
result = generate_fria_draft(ctx)
|
||||
|
||||
assert "Recht auf Bildung" in result["GRUNDRECHTE_ANALYSE"]
|
||||
assert "Rechte des Kindes" in result["GRUNDRECHTE_ANALYSE"]
|
||||
assert result["BILDUNGSKONTEXT"] is True
|
||||
|
||||
def test_healthcare_domain_rights(self):
|
||||
ctx = {
|
||||
"organisation_name": "Klinik",
|
||||
"system_name": "Diagnose-KI",
|
||||
"system_description": "Diagnoseunterstuetzung",
|
||||
"einsatzzweck": "Diagnostik",
|
||||
"domain": "healthcare",
|
||||
}
|
||||
result = generate_fria_draft(ctx)
|
||||
|
||||
assert "Menschenwuerde" in result["GRUNDRECHTE_ANALYSE"]
|
||||
assert "Schutz personenbezogener Daten" in result["GRUNDRECHTE_ANALYSE"]
|
||||
|
||||
def test_universal_rights_always_present(self):
|
||||
for domain in ["hr", "education", "healthcare", "finance", ""]:
|
||||
ctx = {
|
||||
"organisation_name": "Test",
|
||||
"system_name": "Tool",
|
||||
"system_description": "x",
|
||||
"einsatzzweck": "y",
|
||||
"domain": domain,
|
||||
}
|
||||
result = generate_fria_draft(ctx)
|
||||
assert "Schutz personenbezogener Daten" in result["GRUNDRECHTE_ANALYSE"]
|
||||
|
||||
def test_hr_measures_included(self):
|
||||
ctx = {
|
||||
"organisation_name": "Test",
|
||||
"system_name": "Tool",
|
||||
"system_description": "x",
|
||||
"einsatzzweck": "y",
|
||||
"domain": "hr",
|
||||
}
|
||||
result = generate_fria_draft(ctx)
|
||||
|
||||
for measure in HR_MEASURES:
|
||||
assert measure in result["MASSNAHMEN_LISTE"]
|
||||
|
||||
def test_education_measures_included(self):
|
||||
ctx = {
|
||||
"organisation_name": "Test",
|
||||
"system_name": "Tool",
|
||||
"system_description": "x",
|
||||
"einsatzzweck": "y",
|
||||
"domain": "education",
|
||||
}
|
||||
result = generate_fria_draft(ctx)
|
||||
|
||||
for measure in EDUCATION_MEASURES:
|
||||
assert measure in result["MASSNAHMEN_LISTE"]
|
||||
|
||||
def test_public_entity_flag(self):
|
||||
ctx = {
|
||||
"organisation_name": "Behoerde",
|
||||
"system_name": "Tool",
|
||||
"system_description": "x",
|
||||
"einsatzzweck": "y",
|
||||
"is_public_entity": True,
|
||||
}
|
||||
result = generate_fria_draft(ctx)
|
||||
assert result["OEFFENTLICHE_STELLE"] is True
|
||||
|
||||
def test_risk_matrix_generated(self):
|
||||
ctx = {
|
||||
"organisation_name": "Test",
|
||||
"system_name": "Tool",
|
||||
"system_description": "x",
|
||||
"einsatzzweck": "y",
|
||||
"domain": "hr",
|
||||
"risk_score": 60,
|
||||
}
|
||||
result = generate_fria_draft(ctx)
|
||||
|
||||
assert result["RISIKOMATRIX"] != "{{RISIKOMATRIX}}"
|
||||
assert "Nicht-Diskriminierung" in result["RISIKOMATRIX"]
|
||||
|
||||
def test_affected_groups(self):
|
||||
ctx = {
|
||||
"organisation_name": "Test",
|
||||
"system_name": "Tool",
|
||||
"system_description": "x",
|
||||
"einsatzzweck": "y",
|
||||
"affected_groups": ["Bewerber", "Beschaeftigte"],
|
||||
"affected_count": "~500 pro Jahr",
|
||||
}
|
||||
result = generate_fria_draft(ctx)
|
||||
|
||||
assert "Bewerber" in result["BETROFFENE_GRUPPEN"]
|
||||
assert result["BETROFFENE_ANZAHL"] == "~500 pro Jahr"
|
||||
|
||||
|
||||
class TestDSFADomainRisks:
|
||||
"""Tests for domain-specific risks in DSFA generator."""
|
||||
|
||||
def test_hr_domain_adds_agg_risks(self):
|
||||
# Import from dsfa_template
|
||||
from dsfa_template import _generate_risk_assessment
|
||||
|
||||
ctx = {"has_ai_systems": True, "domain": "hr"}
|
||||
output = _generate_risk_assessment(ctx)
|
||||
|
||||
assert "AGG-Verstoss" in output
|
||||
assert "Beweislastumkehr" in output
|
||||
|
||||
def test_education_domain_adds_risks(self):
|
||||
from dsfa_template import _generate_risk_assessment
|
||||
|
||||
ctx = {"has_ai_systems": True, "domain": "education"}
|
||||
output = _generate_risk_assessment(ctx)
|
||||
|
||||
assert "Chancenungleichheit" in output
|
||||
|
||||
def test_healthcare_domain_adds_risks(self):
|
||||
from dsfa_template import _generate_risk_assessment
|
||||
|
||||
ctx = {"has_ai_systems": True, "domain": "healthcare"}
|
||||
output = _generate_risk_assessment(ctx)
|
||||
|
||||
assert "Fehldiagnose" in output
|
||||
|
||||
def test_finance_domain_adds_risks(self):
|
||||
from dsfa_template import _generate_risk_assessment
|
||||
|
||||
ctx = {"has_ai_systems": True, "domain": "finance"}
|
||||
output = _generate_risk_assessment(ctx)
|
||||
|
||||
assert "Kredit-Scoring" in output
|
||||
|
||||
def test_no_domain_no_extra_risks(self):
|
||||
from dsfa_template import _generate_risk_assessment
|
||||
|
||||
ctx = {"has_ai_systems": True}
|
||||
output = _generate_risk_assessment(ctx)
|
||||
|
||||
assert "AGG-Verstoss" not in output
|
||||
assert "Fehldiagnose" not in output
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,350 @@
|
||||
-- Migration 006: Betriebsvereinbarung Template V1
|
||||
-- Modulare Vorlage fuer Betriebsvereinbarungen zu KI/IT-Systemen
|
||||
-- Rechtsgrundlage: §87 Abs.1 Nr.6 BetrVG, DSGVO, BDSG
|
||||
|
||||
INSERT INTO compliance.compliance_legal_templates (
|
||||
tenant_id, document_type, title, description, language, jurisdiction,
|
||||
version, status, license_name, source_name, attribution_required,
|
||||
is_complete_document, placeholders, content
|
||||
) VALUES (
|
||||
'9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'::uuid,
|
||||
'betriebsvereinbarung',
|
||||
'Betriebsvereinbarung — Einfuehrung und Nutzung von KI-/IT-Systemen',
|
||||
'Modulare Vorlage fuer eine Betriebsvereinbarung gemaess §87 Abs.1 Nr.6 BetrVG zur Einfuehrung und Nutzung von IT-Systemen und KI-Anwendungen. Umfasst Datenschutz, Ueberwachungsschutz, Change-Management und Kontrollrechte des Betriebsrats. Basiert auf BAG-Rechtsprechung zu Microsoft 365, SAP ERP und Standardsoftware.',
|
||||
'de',
|
||||
'DE',
|
||||
'1.0',
|
||||
'published',
|
||||
'MIT',
|
||||
'BreakPilot Compliance',
|
||||
false,
|
||||
true,
|
||||
CAST('[
|
||||
"{{UNTERNEHMEN_NAME}}",
|
||||
"{{UNTERNEHMEN_SITZ}}",
|
||||
"{{ARBEITGEBER_VERTRETER}}",
|
||||
"{{BETRIEBSRAT_VORSITZ}}",
|
||||
"{{SYSTEM_NAME}}",
|
||||
"{{SYSTEM_BESCHREIBUNG}}",
|
||||
"{{SYSTEM_HERSTELLER}}",
|
||||
"{{GELTUNGSBEREICH_STANDORTE}}",
|
||||
"{{GELTUNGSBEREICH_BEREICHE}}",
|
||||
"{{GELTUNGSBEREICH_MODULE}}",
|
||||
"{{ZWECK_BESCHREIBUNG}}",
|
||||
"{{DATENARTEN_LISTE}}",
|
||||
"{{VERBOTENE_NUTZUNGEN}}",
|
||||
"{{ROLLEN_ADMIN}}",
|
||||
"{{ROLLEN_FUEHRUNGSKRAFT}}",
|
||||
"{{ROLLEN_REPORTING}}",
|
||||
"{{TRANSPARENZ_INFO}}",
|
||||
"{{ERLAUBTE_REPORTS}}",
|
||||
"{{SPEICHERFRIST_AUDIT_LOGS}}",
|
||||
"{{SPEICHERFRIST_NUTZUNGSDATEN}}",
|
||||
"{{SPEICHERFRIST_CHAT_PROMPTS}}",
|
||||
"{{TOM_MASSNAHMEN}}",
|
||||
"{{CHANGE_MANAGEMENT_PROZESS}}",
|
||||
"{{AUDIT_INTERVALL}}",
|
||||
"{{BESCHWERDE_ANSPRECHPARTNER}}",
|
||||
"{{LAUFZEIT}}",
|
||||
"{{KUENDIGUNGSFRIST}}",
|
||||
"{{DATUM_UNTERZEICHNUNG}}",
|
||||
"{{DSB_NAME}}",
|
||||
"{{DSB_KONTAKT}}"
|
||||
]' AS jsonb),
|
||||
$template$# Betriebsvereinbarung
|
||||
|
||||
**ueber die Einfuehrung und Nutzung von {{SYSTEM_NAME}}**
|
||||
|
||||
zwischen
|
||||
|
||||
**{{UNTERNEHMEN_NAME}}**, {{UNTERNEHMEN_SITZ}},
|
||||
vertreten durch {{ARBEITGEBER_VERTRETER}}
|
||||
(nachfolgend "Arbeitgeberin")
|
||||
|
||||
und dem
|
||||
|
||||
**Betriebsrat** der {{UNTERNEHMEN_NAME}},
|
||||
vertreten durch den/die Vorsitzende/n {{BETRIEBSRAT_VORSITZ}}
|
||||
(nachfolgend "Betriebsrat")
|
||||
|
||||
---
|
||||
|
||||
## A. Praeambel und Rechtsgrundlagen
|
||||
|
||||
Diese Betriebsvereinbarung regelt die Einfuehrung und Nutzung von **{{SYSTEM_NAME}}** ({{SYSTEM_BESCHREIBUNG}}) im Betrieb der {{UNTERNEHMEN_NAME}}.
|
||||
|
||||
**Rechtsgrundlagen:**
|
||||
- §87 Abs.1 Nr.6 BetrVG (Mitbestimmung bei technischen Ueberwachungseinrichtungen)
|
||||
- §90 BetrVG (Unterrichtung bei Planung technischer Anlagen)
|
||||
- Art. 5, 6, 32 DSGVO (Datenschutzgrundsaetze, Rechtsgrundlage, TOM)
|
||||
- §26 BDSG (Beschaeftigtendatenschutz)
|
||||
{{#IF AI_SYSTEM}}
|
||||
- Verordnung (EU) 2024/1689 (KI-Verordnung / AI Act)
|
||||
{{/IF}}
|
||||
|
||||
Die Parteien sind sich einig, dass {{SYSTEM_NAME}} eine technische Einrichtung im Sinne des §87 Abs.1 Nr.6 BetrVG darstellt, die geeignet ist, das Verhalten oder die Leistung der Beschaeftigten zu ueberwachen. Die Einigung erfolgt in Kenntnis der Rechtsprechung des Bundesarbeitsgerichts (vgl. BAG 1 ABR 20/21 — Microsoft Office 365; BAG 1 ABN 36/18 — Standardsoftware).
|
||||
|
||||
---
|
||||
|
||||
## B. Geltungsbereich
|
||||
|
||||
### B.1 Raeumlicher Geltungsbereich
|
||||
Diese Betriebsvereinbarung gilt fuer folgende Standorte:
|
||||
{{GELTUNGSBEREICH_STANDORTE}}
|
||||
|
||||
### B.2 Persoenlicher Geltungsbereich
|
||||
Die Betriebsvereinbarung gilt fuer alle Beschaeftigten der folgenden Bereiche:
|
||||
{{GELTUNGSBEREICH_BEREICHE}}
|
||||
|
||||
### B.3 Sachlicher Geltungsbereich
|
||||
Die Betriebsvereinbarung umfasst folgende Module und Dienste des Systems:
|
||||
{{GELTUNGSBEREICH_MODULE}}
|
||||
|
||||
{{#IF SYSTEM_HERSTELLER}}
|
||||
**Systemhersteller/-anbieter:** {{SYSTEM_HERSTELLER}}
|
||||
{{/IF}}
|
||||
|
||||
---
|
||||
|
||||
## C. Zweckbestimmung
|
||||
|
||||
### C.1 Erlaubte Nutzungszwecke
|
||||
{{SYSTEM_NAME}} darf ausschliesslich zu folgenden Zwecken eingesetzt werden:
|
||||
{{ZWECK_BESCHREIBUNG}}
|
||||
|
||||
### C.2 Verbotene Nutzungen
|
||||
Folgende Nutzungen sind ausdruecklich untersagt:
|
||||
|
||||
{{VERBOTENE_NUTZUNGEN}}
|
||||
|
||||
Darueber hinaus ist generell untersagt:
|
||||
- Verdeckte Leistungs- oder Verhaltenskontrolle einzelner Beschaeftigter
|
||||
- Erstellung individueller Persoenlichkeitsprofile
|
||||
- Nutzung von Prompt-, Chat- oder Nutzungshistorien zu disziplinarischen Zwecken
|
||||
- Automatisierte Personalentscheidungen ohne menschliche Ueberpruefung
|
||||
- Personenbezogene Rankings oder Leistungsvergleiche ohne gesonderte Mitbestimmung
|
||||
{{#IF AI_SYSTEM}}
|
||||
- Einsatz von KI-Funktionen zur biometrischen Echtzeit-Identifizierung
|
||||
- KI-gestuetztes Social Scoring von Beschaeftigten
|
||||
{{/IF}}
|
||||
|
||||
---
|
||||
|
||||
## D. Datenarten und Verarbeitungszwecke
|
||||
|
||||
### D.1 Verarbeitete Datenarten
|
||||
Im Rahmen der Nutzung von {{SYSTEM_NAME}} werden folgende Datenarten verarbeitet:
|
||||
{{DATENARTEN_LISTE}}
|
||||
|
||||
### D.2 Rechtsgrundlage
|
||||
Die Verarbeitung der Beschaeftigtendaten erfolgt auf Grundlage von:
|
||||
- §26 Abs.1 BDSG i.V.m. Art. 6 Abs.1 lit. b DSGVO (Durchfuehrung des Arbeitsverhaeltnisses)
|
||||
- §26 Abs.4 BDSG i.V.m. Art. 88 DSGVO (diese Betriebsvereinbarung als Kollektivvereinbarung)
|
||||
|
||||
### D.3 Keine Verarbeitung besonderer Kategorien
|
||||
Daten gemaess Art. 9 DSGVO (Gesundheitsdaten, Gewerkschaftszugehoerigkeit, biometrische Daten etc.) werden nicht verarbeitet, es sei denn, dies ist in einem gesonderten Anhang zu dieser Betriebsvereinbarung ausdruecklich geregelt.
|
||||
|
||||
---
|
||||
|
||||
## E. Rollen- und Zugriffskonzept
|
||||
|
||||
### E.1 Administratoren
|
||||
{{ROLLEN_ADMIN}}
|
||||
|
||||
### E.2 Fuehrungskraefte
|
||||
{{ROLLEN_FUEHRUNGSKRAFT}}
|
||||
|
||||
Fuehrungskraefte erhalten **keinen** Zugriff auf:
|
||||
- individuelle Nutzungsprotokolle
|
||||
- Prompt-/Chat-Historien einzelner Beschaeftigter
|
||||
- Produktivitaetskennzahlen auf Personenebene
|
||||
|
||||
### E.3 Reporting-Zugriff
|
||||
{{ROLLEN_REPORTING}}
|
||||
|
||||
### E.4 Vier-Augen-Prinzip
|
||||
Sonderauswertungen mit Personenbezug beduerfen:
|
||||
- der Zustimmung des Betriebsrats
|
||||
- der Beteiligung des Datenschutzbeauftragten ({{DSB_NAME}}, {{DSB_KONTAKT}})
|
||||
- einer dokumentierten Begruendung
|
||||
|
||||
---
|
||||
|
||||
## F. Transparenz gegenueber Beschaeftigten
|
||||
|
||||
Die Arbeitgeberin informiert alle Beschaeftigten vor Einfuehrung von {{SYSTEM_NAME}} ueber:
|
||||
{{TRANSPARENZ_INFO}}
|
||||
|
||||
Insbesondere:
|
||||
- Welche Daten verarbeitet werden
|
||||
- Welche KI-Funktionen aktiviert sind
|
||||
- Welche Protokollierung stattfindet
|
||||
- Wer Zugriff auf welche Daten hat
|
||||
- Wie lange Daten gespeichert werden
|
||||
- An wen sich Beschaeftigte bei Fragen oder Beschwerden wenden koennen
|
||||
|
||||
{{#IF AI_SYSTEM}}
|
||||
Bei KI-gestuetzten Funktionen wird zusaetzlich transparent gemacht:
|
||||
- Ob und wie KI-generierte Inhalte gekennzeichnet werden
|
||||
- Ob Eingaben fuer Modelltraining verwendet werden (Standard: Nein)
|
||||
- Welche Entscheidungsunterstuetzung die KI leistet
|
||||
{{/IF}}
|
||||
|
||||
---
|
||||
|
||||
## G. Auswertungen und Reports
|
||||
|
||||
### G.1 Erlaubte Reports
|
||||
Folgende Auswertungen sind ohne gesonderte Zustimmung zulaessig:
|
||||
{{ERLAUBTE_REPORTS}}
|
||||
|
||||
### G.2 Unzulaessige Reports
|
||||
Ohne ausdrueckliche, vorherige Zustimmung des Betriebsrats sind unzulaessig:
|
||||
- individuelle Produktivitaetsreports
|
||||
- Teamvergleiche mit Personenbezug
|
||||
- Verhaltensprofile oder Nutzungsmuster einzelner Beschaeftigter
|
||||
- Rankinglisten (auch anonymisierte, wenn Re-Identifikation moeglich)
|
||||
- Korrelation von Nutzungsdaten mit Leistungsbeurteilungen
|
||||
|
||||
### G.3 Neue Reporttypen
|
||||
Die Einfuehrung neuer Reporttypen bedarf der vorherigen Zustimmung des Betriebsrats.
|
||||
|
||||
---
|
||||
|
||||
## H. Speicher- und Loeschfristen
|
||||
|
||||
| Datenkategorie | Speicherfrist | Loeschverfahren |
|
||||
|----------------|---------------|-----------------|
|
||||
| Audit-/Admin-Logs | {{SPEICHERFRIST_AUDIT_LOGS}} | Automatische Loeschung |
|
||||
| Nutzungsdaten (aggregiert) | {{SPEICHERFRIST_NUTZUNGSDATEN}} | Automatische Loeschung |
|
||||
| Prompt-/Chat-Historien | {{SPEICHERFRIST_CHAT_PROMPTS}} | Automatische Loeschung oder deaktiviert |
|
||||
| Exportdateien | 30 Tage | Automatische Loeschung |
|
||||
|
||||
Die Speicherdauer der Audit-Logs orientiert sich am berechtigten Interesse der Arbeitgeberin an der Systemsicherheit und wird auf das erforderliche Minimum begrenzt.
|
||||
|
||||
{{#IF AI_SYSTEM}}
|
||||
**KI-spezifisch:**
|
||||
- Trainingsdaten aus Beschaeftigten-Interaktionen: **nicht zulaessig** ohne gesonderte Vereinbarung
|
||||
- Feedback-Daten zur Modellverbesserung: nur anonymisiert und aggregiert
|
||||
{{/IF}}
|
||||
|
||||
---
|
||||
|
||||
## I. Technische und organisatorische Massnahmen (TOM)
|
||||
|
||||
Zum Schutz der Beschaeftigtendaten werden folgende Massnahmen umgesetzt:
|
||||
|
||||
{{TOM_MASSNAHMEN}}
|
||||
|
||||
Ergaenzend gelten mindestens:
|
||||
- Rollen- und Rechtekonzept mit Least-Privilege-Prinzip
|
||||
- Verschluesselung der Daten bei Uebertragung und Speicherung
|
||||
- Protokollierung aller administrativen Zugriffe
|
||||
- Pseudonymisierung, wo technisch moeglich
|
||||
- Deaktivierung nicht benoetigter Telemetrie- und Diagnosefunktionen
|
||||
- Getrennte Umgebungen fuer Test und Produktion
|
||||
|
||||
---
|
||||
|
||||
## J. Change-Management
|
||||
|
||||
### J.1 Aenderungspflicht
|
||||
Folgende Aenderungen an {{SYSTEM_NAME}} beduerfen der vorherigen Information und ggf. erneuten Mitbestimmung des Betriebsrats:
|
||||
|
||||
{{CHANGE_MANAGEMENT_PROZESS}}
|
||||
|
||||
Insbesondere:
|
||||
- Aktivierung neuer Module oder Funktionen
|
||||
- Anbindung neuer Datenquellen oder Konnektoren
|
||||
- Aenderung der Reporting-Funktionalitaet
|
||||
- Updates mit neuen KI-Modellen oder -Funktionen
|
||||
- Aenderung der Datenverarbeitungsstandorte
|
||||
- Erweiterung des Nutzerkreises
|
||||
|
||||
### J.2 Informationsfrist
|
||||
Die Arbeitgeberin informiert den Betriebsrat mindestens **14 Kalendertage** vor geplanten Aenderungen schriftlich. Bei sicherheitskritischen Updates kann die Frist auf 3 Werktage verkuerzt werden.
|
||||
|
||||
### J.3 Bewertungsverfahren
|
||||
Jede Aenderung wird anhand folgender Kriterien bewertet:
|
||||
- Aendert sich die Ueberwachungseignung?
|
||||
- Werden neue Datenarten verarbeitet?
|
||||
- Aendert sich der Personenbezug?
|
||||
|
||||
Bei positiver Beantwortung einer dieser Fragen ist eine erneute Mitbestimmung erforderlich.
|
||||
|
||||
---
|
||||
|
||||
## K. Kontroll- und Audit-Rechte des Betriebsrats
|
||||
|
||||
### K.1 Laufende Kontrolle
|
||||
Der Betriebsrat hat das Recht auf:
|
||||
- Einsicht in die Systemdokumentation
|
||||
- Einsicht in den Katalog aktiver Reports und Auswertungen
|
||||
- Information ueber alle Administrationszugriffe
|
||||
- Teilnahme an Schulungen zum System
|
||||
|
||||
### K.2 Regelmaessige Reviews
|
||||
Arbeitgeberin und Betriebsrat fuehren alle **{{AUDIT_INTERVALL}}** einen gemeinsamen Review durch. Gegenstand:
|
||||
- Aktuelle Nutzung und Funktionsumfang
|
||||
- Eingehaltene/verletzte Regelungen
|
||||
- Eingegangene Beschwerden
|
||||
- Geplante Aenderungen
|
||||
- Aktualitaet der TOM
|
||||
|
||||
### K.3 Anlassbezogene Pruefung
|
||||
Bei begruendetem Verdacht auf Verstoss gegen diese Betriebsvereinbarung kann der Betriebsrat jederzeit eine Sonderpruefung verlangen. Die Arbeitgeberin stellt innerhalb von 5 Werktagen die angeforderten Informationen bereit.
|
||||
|
||||
---
|
||||
|
||||
## L. Beschwerden und Eskalation
|
||||
|
||||
### L.1 Beschwerderecht
|
||||
Beschaeftigte koennen sich bei Bedenken hinsichtlich der Datenverarbeitung wenden an:
|
||||
{{BESCHWERDE_ANSPRECHPARTNER}}
|
||||
|
||||
### L.2 Eskalation
|
||||
Bei Meinungsverschiedenheiten ueber die Auslegung oder Anwendung dieser Betriebsvereinbarung gilt:
|
||||
1. Gespraech zwischen Arbeitgeberin und Betriebsrat (Frist: 2 Wochen)
|
||||
2. Hinzuziehung des Datenschutzbeauftragten
|
||||
3. Einigungsstelle gemaess §76 BetrVG
|
||||
|
||||
### L.3 Sofortmassnahmen
|
||||
Bei schwerwiegenden Verstoessen (insbesondere unzulaessige Ueberwachung, Datenmissbrauch) kann der Betriebsrat die sofortige Aussetzung der betroffenen Funktion verlangen. Die Arbeitgeberin setzt die Funktion bis zur Klaerung aus.
|
||||
|
||||
---
|
||||
|
||||
## M. Schlussbestimmungen
|
||||
|
||||
### M.1 Inkrafttreten und Laufzeit
|
||||
Diese Betriebsvereinbarung tritt am {{DATUM_UNTERZEICHNUNG}} in Kraft und gilt fuer die Dauer von {{LAUFZEIT}}.
|
||||
|
||||
### M.2 Kuendigung
|
||||
Die Betriebsvereinbarung kann von jeder Seite mit einer Frist von {{KUENDIGUNGSFRIST}} zum Monatsende schriftlich gekuendigt werden.
|
||||
|
||||
### M.3 Nachwirkung
|
||||
Die Betriebsvereinbarung wirkt nach Kuendigung bis zum Abschluss einer neuen Vereinbarung nach (§77 Abs.6 BetrVG).
|
||||
|
||||
### M.4 Salvatorische Klausel
|
||||
Sollten einzelne Bestimmungen unwirksam sein, bleibt die Wirksamkeit der uebrigen Bestimmungen unberuehrt. Die Parteien verpflichten sich, unwirksame Bestimmungen durch wirksame zu ersetzen, die dem wirtschaftlichen Zweck am naechsten kommen.
|
||||
|
||||
### M.5 Anlagen
|
||||
Folgende Anlagen sind Bestandteil dieser Betriebsvereinbarung:
|
||||
- Anlage 1: Detaillierte Systemdokumentation
|
||||
- Anlage 2: Rollen- und Rechtekonzept
|
||||
- Anlage 3: TOM-Dokumentation
|
||||
- Anlage 4: Reportkatalog
|
||||
{{#IF AI_SYSTEM}}
|
||||
- Anlage 5: KI-Transparenzbericht
|
||||
{{/IF}}
|
||||
|
||||
---
|
||||
|
||||
**{{UNTERNEHMEN_SITZ}}, den {{DATUM_UNTERZEICHNUNG}}**
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| _________________________ | _________________________ |
|
||||
| {{ARBEITGEBER_VERTRETER}} | {{BETRIEBSRAT_VORSITZ}} |
|
||||
| fuer die Arbeitgeberin | fuer den Betriebsrat |
|
||||
$template$
|
||||
) ON CONFLICT DO NOTHING;
|
||||
@@ -0,0 +1,330 @@
|
||||
-- Migration 007: FRIA Template V1 — Grundrechte-Folgenabschaetzung (Art. 27 KI-VO)
|
||||
-- Fundamental Rights Impact Assessment fuer Hochrisiko-KI-Systeme
|
||||
-- Rechtsgrundlage: Art. 27 Verordnung (EU) 2024/1689 (KI-Verordnung / AI Act)
|
||||
|
||||
INSERT INTO compliance.compliance_legal_templates (
|
||||
tenant_id, document_type, title, description, language, jurisdiction,
|
||||
version, status, license_name, source_name, attribution_required,
|
||||
is_complete_document, placeholders, content
|
||||
) VALUES (
|
||||
'9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'::uuid,
|
||||
'fria',
|
||||
'Grundrechte-Folgenabschaetzung (FRIA) gemaess Art. 27 KI-Verordnung',
|
||||
'Vorlage fuer eine Grundrechte-Folgenabschaetzung (Fundamental Rights Impact Assessment) gemaess Art. 27 der Verordnung (EU) 2024/1689 (KI-Verordnung). Erforderlich fuer Hochrisiko-KI-Systeme, insbesondere bei oeffentlichen Stellen und in den Bereichen Beschaeftigung, Bildung und Zugang zu wesentlichen Dienstleistungen.',
|
||||
'de',
|
||||
'EU/KI-VO',
|
||||
'1.0',
|
||||
'published',
|
||||
'MIT',
|
||||
'BreakPilot Compliance',
|
||||
false,
|
||||
true,
|
||||
CAST('[
|
||||
"{{ORGANISATION_NAME}}",
|
||||
"{{ORGANISATION_ADRESSE}}",
|
||||
"{{VERANTWORTLICHER}}",
|
||||
"{{ERSTELLT_VON}}",
|
||||
"{{ERSTELLT_AM}}",
|
||||
"{{SYSTEM_NAME}}",
|
||||
"{{SYSTEM_VERSION}}",
|
||||
"{{SYSTEM_BESCHREIBUNG}}",
|
||||
"{{SYSTEM_ANBIETER}}",
|
||||
"{{EINSATZZWECK}}",
|
||||
"{{EINSATZKONTEXT}}",
|
||||
"{{BETROFFENE_GRUPPEN}}",
|
||||
"{{BETROFFENE_ANZAHL}}",
|
||||
"{{GRUNDRECHTE_ANALYSE}}",
|
||||
"{{RISIKOMATRIX}}",
|
||||
"{{MASSNAHMEN_LISTE}}",
|
||||
"{{HUMAN_OVERSIGHT_BESCHREIBUNG}}",
|
||||
"{{TRANSPARENZ_MASSNAHMEN}}",
|
||||
"{{KONSULTATION_ERGEBNISSE}}",
|
||||
"{{GENEHMIGT_VON}}",
|
||||
"{{GENEHMIGT_AM}}",
|
||||
"{{NAECHSTE_UEBERPRUEFUNG}}",
|
||||
"{{DSB_NAME}}",
|
||||
"{{DSB_KONTAKT}}",
|
||||
"{{AI_ACT_KLASSIFIKATION}}",
|
||||
"{{ANNEX_III_KATEGORIE}}"
|
||||
]' AS jsonb),
|
||||
$template$# Grundrechte-Folgenabschaetzung (FRIA)
|
||||
|
||||
**gemaess Art. 27 der Verordnung (EU) 2024/1689 (KI-Verordnung)**
|
||||
|
||||
---
|
||||
|
||||
| Feld | Wert |
|
||||
|------|------|
|
||||
| Organisation | {{ORGANISATION_NAME}} |
|
||||
| Adresse | {{ORGANISATION_ADRESSE}} |
|
||||
| KI-System | {{SYSTEM_NAME}} (Version {{SYSTEM_VERSION}}) |
|
||||
| Erstellt von | {{ERSTELLT_VON}} |
|
||||
| Erstellt am | {{ERSTELLT_AM}} |
|
||||
| Status | Entwurf |
|
||||
|
||||
---
|
||||
|
||||
## 1. Systembeschreibung und Einsatzkontext
|
||||
|
||||
### 1.1 KI-System
|
||||
|
||||
**Systemname:** {{SYSTEM_NAME}}
|
||||
**Version:** {{SYSTEM_VERSION}}
|
||||
**Anbieter:** {{SYSTEM_ANBIETER}}
|
||||
**Beschreibung:** {{SYSTEM_BESCHREIBUNG}}
|
||||
|
||||
### 1.2 AI Act Klassifikation
|
||||
|
||||
**Risikoklasse:** {{AI_ACT_KLASSIFIKATION}}
|
||||
{{#IF ANNEX_III_KATEGORIE}}
|
||||
**Annex III Kategorie:** {{ANNEX_III_KATEGORIE}}
|
||||
{{/IF}}
|
||||
|
||||
### 1.3 Einsatzzweck
|
||||
|
||||
{{EINSATZZWECK}}
|
||||
|
||||
### 1.4 Einsatzkontext
|
||||
|
||||
{{EINSATZKONTEXT}}
|
||||
|
||||
Folgende Fragen sind zu beantworten:
|
||||
- In welchem organisatorischen Kontext wird das System eingesetzt?
|
||||
- Welche Entscheidungen werden durch das System unterstuetzt oder automatisiert?
|
||||
- Wie haeufig wird das System eingesetzt?
|
||||
- Welche Rolle spielt das System im Gesamtprozess?
|
||||
|
||||
### 1.5 Betroffene Personengruppen
|
||||
|
||||
{{BETROFFENE_GRUPPEN}}
|
||||
|
||||
**Geschaetzte Anzahl betroffener Personen:** {{BETROFFENE_ANZAHL}}
|
||||
|
||||
{{#IF BILDUNGSKONTEXT}}
|
||||
**Besonderer Schutz:** Schueler, Studierende und Auszubildende geniessen als besonders schutzbeduerftiger Personenkreis erhoehten Schutz.
|
||||
{{/IF}}
|
||||
|
||||
{{#IF HR_KONTEXT}}
|
||||
**Besonderer Schutz:** Beschaeftigte und Bewerber befinden sich in einem Abhaengigkeitsverhaeltnis und beduerfen besonderen Schutzes vor diskriminierenden KI-Entscheidungen.
|
||||
{{/IF}}
|
||||
|
||||
---
|
||||
|
||||
## 2. Grundrechte-Mapping
|
||||
|
||||
### 2.1 Betroffene Grundrechte
|
||||
|
||||
Die folgenden Grundrechte der EU-Grundrechtecharta und des Grundgesetzes wurden auf Betroffenheit geprueft:
|
||||
|
||||
{{GRUNDRECHTE_ANALYSE}}
|
||||
|
||||
### 2.2 Referenz-Grundrechte
|
||||
|
||||
| Nr. | Grundrecht | EU-Charta | GG | Betroffen | Begruendung |
|
||||
|-----|-----------|-----------|-----|-----------|-------------|
|
||||
| 1 | Menschenwuerde | Art. 1 | Art. 1 | | |
|
||||
| 2 | Recht auf Privatsphaere | Art. 7 | Art. 2 Abs. 1 | | |
|
||||
| 3 | Schutz personenbezogener Daten | Art. 8 | Art. 2 Abs. 1 i.V.m. Art. 1 Abs. 1 | | |
|
||||
| 4 | Nicht-Diskriminierung | Art. 21 | Art. 3 | | |
|
||||
| 5 | Gleichheit von Frauen und Maennern | Art. 23 | Art. 3 Abs. 2 | | |
|
||||
| 6 | Rechte des Kindes | Art. 24 | Art. 6 Abs. 2 | | |
|
||||
| 7 | Recht auf Bildung | Art. 14 | Art. 12 | | |
|
||||
| 8 | Berufsfreiheit / Recht zu arbeiten | Art. 15 | Art. 12 | | |
|
||||
| 9 | Recht auf wirksamen Rechtsbehelf | Art. 47 | Art. 19 Abs. 4 | | |
|
||||
| 10 | Meinungs- und Informationsfreiheit | Art. 11 | Art. 5 | | |
|
||||
| 11 | Versammlungs- und Vereinigungsfreiheit | Art. 12 | Art. 8, 9 | | |
|
||||
| 12 | Recht auf soziale Sicherheit | Art. 34 | Art. 20 | | |
|
||||
|
||||
{{#IF OEFFENTLICHE_STELLE}}
|
||||
|
||||
### 2.3 Besondere Pflichten oeffentlicher Stellen
|
||||
|
||||
Als oeffentliche Stelle gelten zusaetzliche Anforderungen:
|
||||
- Erweiterte Transparenzpflicht gegenueber Buergern
|
||||
- Pflicht zur Barrierefreiheit des Systems
|
||||
- Beruecksichtigung des Gleichheitsgrundsatzes (Art. 3 GG)
|
||||
- Demokratische Kontrolle und Rechenschaftspflicht
|
||||
{{/IF}}
|
||||
|
||||
---
|
||||
|
||||
## 3. Risikoanalyse
|
||||
|
||||
### 3.1 Risikobewertung pro Grundrecht
|
||||
|
||||
Fuer jedes betroffene Grundrecht wird das Risiko bewertet:
|
||||
|
||||
**Eintrittswahrscheinlichkeit:**
|
||||
- 1 = Sehr unwahrscheinlich
|
||||
- 2 = Unwahrscheinlich
|
||||
- 3 = Moeglich
|
||||
- 4 = Wahrscheinlich
|
||||
- 5 = Sehr wahrscheinlich
|
||||
|
||||
**Schadensausmass:**
|
||||
- 1 = Geringfuegig
|
||||
- 2 = Begrenzt
|
||||
- 3 = Erheblich
|
||||
- 4 = Schwerwiegend
|
||||
- 5 = Katastrophal
|
||||
|
||||
### 3.2 Risikomatrix
|
||||
|
||||
{{RISIKOMATRIX}}
|
||||
|
||||
| Grundrecht | Risikoszenario | Wahrscheinlichkeit | Schwere | Risiko-Level | Begruendung |
|
||||
|-----------|----------------|--------------------:|--------:|:------------:|-------------|
|
||||
| | | | | | |
|
||||
|
||||
**Risiko-Level Berechnung:** Wahrscheinlichkeit × Schwere
|
||||
|
||||
| Risiko-Level | Punktzahl | Bedeutung |
|
||||
|:------------:|:---------:|-----------|
|
||||
| Niedrig | 1-6 | Akzeptables Risiko, Standardmassnahmen |
|
||||
| Mittel | 7-12 | Erhoehte Aufmerksamkeit, zusaetzliche Massnahmen |
|
||||
| Hoch | 13-19 | Erhebliches Risiko, umfassende Massnahmen erforderlich |
|
||||
| Kritisch | 20-25 | Nicht akzeptabel ohne fundamentale Aenderungen |
|
||||
|
||||
---
|
||||
|
||||
## 4. Massnahmen zur Risikominderung
|
||||
|
||||
### 4.1 Uebersicht der Massnahmen
|
||||
|
||||
{{MASSNAHMEN_LISTE}}
|
||||
|
||||
### 4.2 Human Oversight (Art. 14 KI-VO)
|
||||
|
||||
{{HUMAN_OVERSIGHT_BESCHREIBUNG}}
|
||||
|
||||
Folgende Massnahmen zur menschlichen Aufsicht werden umgesetzt:
|
||||
- [ ] Mensch kann KI-Entscheidung jederzeit uebersteuern
|
||||
- [ ] Mensch versteht KI-Output vollstaendig
|
||||
- [ ] Keine automatisierten Entscheidungen ohne menschliche Ueberpruefung
|
||||
- [ ] Schulung der Nutzer zu Systemgrenzen und Risiken
|
||||
- [ ] Eingriffsprotokolle werden gefuehrt
|
||||
|
||||
### 4.3 Transparenz (Art. 13 KI-VO)
|
||||
|
||||
{{TRANSPARENZ_MASSNAHMEN}}
|
||||
|
||||
Folgende Transparenzmassnahmen werden umgesetzt:
|
||||
- [ ] Betroffene werden ueber KI-Nutzung informiert
|
||||
- [ ] KI-generierte Outputs sind als solche gekennzeichnet
|
||||
- [ ] Erklaerbarkeit der Entscheidungslogik sichergestellt
|
||||
- [ ] Kontaktmoeglichkeit fuer Betroffene vorhanden
|
||||
- [ ] Informationen sind verstaendlich und zugaenglich
|
||||
|
||||
### 4.4 Logging und Audit (Art. 12 KI-VO)
|
||||
|
||||
- [ ] Alle Eingaben und Ausgaben werden protokolliert
|
||||
- [ ] Logs sind manipulationssicher
|
||||
- [ ] Aufbewahrungsfristen definiert
|
||||
- [ ] Audit-Trail fuer Entscheidungsnachvollziehbarkeit
|
||||
|
||||
### 4.5 Bias-Pruefung und Nicht-Diskriminierung
|
||||
|
||||
- [ ] Trainingsdaten auf Bias geprueft
|
||||
- [ ] Regelmaessige Bias-Audits geplant
|
||||
- [ ] Beschwerdemechanismus fuer Diskriminierungsfaelle
|
||||
{{#IF HR_KONTEXT}}
|
||||
- [ ] AGG-konforme Gestaltung (kein Bias bei Geschlecht, Alter, Herkunft, Behinderung)
|
||||
- [ ] Betriebsrat gemaess §95 BetrVG beteiligt (bei Auswahlrichtlinien)
|
||||
{{/IF}}
|
||||
{{#IF BILDUNGSKONTEXT}}
|
||||
- [ ] Chancengleichheit unabhaengig von sozioekonomischem Hintergrund
|
||||
- [ ] Keine Benachteiligung aufgrund von Sprachkenntnissen oder Behinderung
|
||||
{{/IF}}
|
||||
|
||||
---
|
||||
|
||||
## 5. Konsultation
|
||||
|
||||
### 5.1 Einbeziehung Betroffener
|
||||
|
||||
{{KONSULTATION_ERGEBNISSE}}
|
||||
|
||||
Folgende Stakeholder wurden konsultiert:
|
||||
- [ ] Datenschutzbeauftragter ({{DSB_NAME}}, {{DSB_KONTAKT}})
|
||||
- [ ] Betroffene Personengruppen oder deren Vertreter
|
||||
{{#IF HR_KONTEXT}}
|
||||
- [ ] Betriebsrat / Personalrat
|
||||
{{/IF}}
|
||||
{{#IF OEFFENTLICHE_STELLE}}
|
||||
- [ ] Buergervertreter / Ombudsstelle
|
||||
- [ ] Zustaendige Aufsichtsbehoerde
|
||||
{{/IF}}
|
||||
- [ ] Fachexperten fuer betroffene Grundrechte
|
||||
|
||||
### 5.2 Ergebnisse der Konsultation
|
||||
|
||||
| Stakeholder | Datum | Ergebnis | Massnahme |
|
||||
|------------|-------|----------|-----------|
|
||||
| | | | |
|
||||
|
||||
---
|
||||
|
||||
## 6. Gesamtbewertung und Freigabe
|
||||
|
||||
### 6.1 Gesamtrisiko-Bewertung
|
||||
|
||||
| Kriterium | Bewertung |
|
||||
|-----------|-----------|
|
||||
| Hoechstes Einzelrisiko | |
|
||||
| Anzahl betroffene Grundrechte | |
|
||||
| Anzahl betroffene Personen | {{BETROFFENE_ANZAHL}} |
|
||||
| Massnahmen ausreichend | Ja / Nein / Teilweise |
|
||||
| Restrisiko akzeptabel | Ja / Nein |
|
||||
|
||||
### 6.2 Entscheidung
|
||||
|
||||
- [ ] **Freigabe** — Restrisiko akzeptabel, Massnahmen ausreichend
|
||||
- [ ] **Freigabe mit Auflagen** — Zusaetzliche Massnahmen erforderlich (siehe unten)
|
||||
- [ ] **Ablehnung** — Grundrechtsrisiken nicht akzeptabel mitigierbar
|
||||
|
||||
### 6.3 Auflagen (falls zutreffend)
|
||||
|
||||
| Nr. | Auflage | Frist | Verantwortlich |
|
||||
|-----|---------|-------|----------------|
|
||||
| | | | |
|
||||
|
||||
---
|
||||
|
||||
## 7. Laufende Ueberwachung
|
||||
|
||||
### 7.1 Naechste Ueberpruefung
|
||||
|
||||
**Geplante Ueberpruefung:** {{NAECHSTE_UEBERPRUEFUNG}}
|
||||
|
||||
### 7.2 Trigger fuer ausserplanmaessige Ueberpruefung
|
||||
|
||||
Eine erneute FRIA ist durchzufuehren bei:
|
||||
- Wesentlicher Aenderung des KI-Systems oder seines Einsatzzwecks
|
||||
- Erweiterung auf neue Personengruppen oder Anwendungsbereiche
|
||||
- Beschwerden oder Vorfaellen mit Grundrechtsbezug
|
||||
- Aenderung der Rechtsgrundlage oder Risikoklassifikation
|
||||
- Neuen wissenschaftlichen Erkenntnissen zu Risiken
|
||||
- Aenderung des KI-Modells oder der Trainingsdaten
|
||||
|
||||
### 7.3 Dokumentation und Archivierung
|
||||
|
||||
Diese FRIA wird mindestens fuer die Dauer des Einsatzes des KI-Systems und darueberhinaus fuer 10 Jahre archiviert (Art. 18 KI-VO).
|
||||
|
||||
---
|
||||
|
||||
## 8. Unterschriften
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| _________________________ | _________________________ |
|
||||
| {{ERSTELLT_VON}} | {{GENEHMIGT_VON}} |
|
||||
| Erstellt am {{ERSTELLT_AM}} | Genehmigt am {{GENEHMIGT_AM}} |
|
||||
|
||||
---
|
||||
|
||||
**Anhang A:** Vollstaendige Systemdokumentation (Art. 11 KI-VO)
|
||||
**Anhang B:** AI Act Decision Tree Ergebnis
|
||||
**Anhang C:** Verknuepfte DSFA (falls vorhanden)
|
||||
**Anhang D:** Konsultationsprotokolle
|
||||
$template$
|
||||
) ON CONFLICT DO NOTHING;
|
||||
@@ -760,3 +760,33 @@ server {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
|
||||
# =========================================================
|
||||
# PITCH DECK: Investor Presentation on port 3012
|
||||
# =========================================================
|
||||
server {
|
||||
listen 3012 ssl;
|
||||
http2 on;
|
||||
server_name macmini localhost;
|
||||
|
||||
ssl_certificate /etc/nginx/certs/macmini.crt;
|
||||
ssl_certificate_key /etc/nginx/certs/macmini.key;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
|
||||
ssl_prefer_server_ciphers off;
|
||||
|
||||
location / {
|
||||
set $upstream_pitch bp-core-pitch-deck:3000;
|
||||
proxy_pass http://$upstream_pitch;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 300s;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,10 @@ RUN npm install
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Embed git commit hash into build
|
||||
ARG GIT_SHA=dev
|
||||
ENV GIT_SHA=$GIT_SHA
|
||||
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
|
||||
Tue Apr 14 09:22:10 AM CEST 2026
|
||||
|
||||
Tue Apr 14 09:27:05 AM CEST 2026
|
||||
Tue Apr 14 09:32:36 AM CEST 2026
|
||||
Tue Apr 15 rebuild trigger
|
||||
Tue Apr 15 rebuild 2
|
||||
@@ -0,0 +1,294 @@
|
||||
/**
|
||||
* Regression test for the "lost access" scenario:
|
||||
*
|
||||
* 1. Admin invites investor A → token T1 is created and emailed.
|
||||
* 2. Investor A opens the link successfully → T1 is marked used_at.
|
||||
* 3. Investor A clears their session (or a redeploy drops cookies).
|
||||
* 4. Investor A returns to / — redirected to /auth.
|
||||
* 5. Without this feature, A is stuck: T1 is already used, expired, or the
|
||||
* session is gone, and there is no self-service way to get back in.
|
||||
* 6. With this feature, A enters their email on /auth and the endpoint
|
||||
* issues a brand new, unused magic link T2 for the same investor row.
|
||||
*
|
||||
* This test wires together the request-link handler with the real verify
|
||||
* handler against an in-memory fake of the two tables the flow touches
|
||||
* (pitch_investors, pitch_magic_links) so we can assert end-to-end that a
|
||||
* second link works after the first one was used.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { NextRequest } from 'next/server'
|
||||
|
||||
// ---- In-memory fake of the two tables touched by this flow ----
|
||||
|
||||
interface InvestorRow {
|
||||
id: string
|
||||
email: string
|
||||
name: string | null
|
||||
company: string | null
|
||||
status: 'invited' | 'active' | 'revoked'
|
||||
last_login_at: Date | null
|
||||
login_count: number
|
||||
}
|
||||
interface MagicLinkRow {
|
||||
id: string
|
||||
investor_id: string
|
||||
token: string
|
||||
expires_at: Date
|
||||
used_at: Date | null
|
||||
ip_address: string | null
|
||||
user_agent: string | null
|
||||
}
|
||||
|
||||
const db = {
|
||||
investors: [] as InvestorRow[],
|
||||
magicLinks: [] as MagicLinkRow[],
|
||||
sessions: [] as { id: string; investor_id: string; ip_address: string | null }[],
|
||||
}
|
||||
|
||||
let idCounter = 0
|
||||
const nextId = () => `row-${++idCounter}`
|
||||
|
||||
// A tiny query router: match the SQL fragment we care about, ignore the rest.
|
||||
const queryMock = vi.fn(async (sql: string, params: unknown[] = []) => {
|
||||
const s = sql.replace(/\s+/g, ' ').trim()
|
||||
|
||||
// Investor lookup by email (used by request-link)
|
||||
if (/SELECT id, email, name, status FROM pitch_investors WHERE email = \$1/i.test(s)) {
|
||||
const row = db.investors.find(i => i.email === params[0])
|
||||
return { rows: row ? [row] : [] }
|
||||
}
|
||||
|
||||
// Insert magic link
|
||||
if (/INSERT INTO pitch_magic_links \(investor_id, token, expires_at\)/i.test(s)) {
|
||||
db.magicLinks.push({
|
||||
id: nextId(),
|
||||
investor_id: params[0] as string,
|
||||
token: params[1] as string,
|
||||
expires_at: params[2] as Date,
|
||||
used_at: null,
|
||||
ip_address: null,
|
||||
user_agent: null,
|
||||
})
|
||||
return { rows: [] }
|
||||
}
|
||||
|
||||
// Verify: magic link + investor JOIN lookup
|
||||
if (/FROM pitch_magic_links ml JOIN pitch_investors i/i.test(s)) {
|
||||
const link = db.magicLinks.find(ml => ml.token === params[0])
|
||||
if (!link) return { rows: [] }
|
||||
const inv = db.investors.find(i => i.id === link.investor_id)!
|
||||
return {
|
||||
rows: [{
|
||||
id: link.id,
|
||||
investor_id: link.investor_id,
|
||||
expires_at: link.expires_at,
|
||||
used_at: link.used_at,
|
||||
email: inv.email,
|
||||
investor_status: inv.status,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
// Mark magic link used
|
||||
if (/UPDATE pitch_magic_links SET used_at = NOW/i.test(s)) {
|
||||
const link = db.magicLinks.find(ml => ml.id === params[2])
|
||||
if (link) {
|
||||
link.used_at = new Date()
|
||||
link.ip_address = params[0] as string | null
|
||||
link.user_agent = params[1] as string | null
|
||||
}
|
||||
return { rows: [] }
|
||||
}
|
||||
|
||||
// Activate investor
|
||||
if (/UPDATE pitch_investors SET status = 'active'/i.test(s)) {
|
||||
const inv = db.investors.find(i => i.id === params[0])
|
||||
if (inv) {
|
||||
inv.status = 'active'
|
||||
inv.last_login_at = new Date()
|
||||
inv.login_count += 1
|
||||
}
|
||||
return { rows: [] }
|
||||
}
|
||||
|
||||
// createSession: revoke prior sessions (no-op in fake)
|
||||
if (/UPDATE pitch_sessions SET revoked = true WHERE investor_id/i.test(s)) {
|
||||
return { rows: [] }
|
||||
}
|
||||
|
||||
// createSession: insert
|
||||
if (/INSERT INTO pitch_sessions/i.test(s)) {
|
||||
const id = nextId()
|
||||
db.sessions.push({ id, investor_id: params[0] as string, ip_address: params[2] as string | null })
|
||||
return { rows: [{ id }] }
|
||||
}
|
||||
|
||||
// createSession: fetch investor email for JWT
|
||||
if (/SELECT email FROM pitch_investors WHERE id = \$1/i.test(s)) {
|
||||
const inv = db.investors.find(i => i.id === params[0])
|
||||
return { rows: inv ? [{ email: inv.email }] : [] }
|
||||
}
|
||||
|
||||
// new-ip detection query (verify route)
|
||||
if (/SELECT DISTINCT ip_address FROM pitch_sessions/i.test(s)) {
|
||||
return { rows: [] }
|
||||
}
|
||||
|
||||
// Audit log insert — accept everything
|
||||
if (/INSERT INTO pitch_audit_logs/i.test(s)) {
|
||||
return { rows: [] }
|
||||
}
|
||||
|
||||
throw new Error(`Unmocked query: ${s.slice(0, 120)}…`)
|
||||
})
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
default: { query: (...args: unknown[]) => queryMock(args[0] as string, args[1] as unknown[]) },
|
||||
}))
|
||||
|
||||
// Capture emails instead of sending them
|
||||
const sentEmails: Array<{ to: string; url: string }> = []
|
||||
vi.mock('@/lib/email', () => ({
|
||||
sendMagicLinkEmail: vi.fn(async (to: string, _name: string | null, url: string) => {
|
||||
sentEmails.push({ to, url })
|
||||
}),
|
||||
}))
|
||||
|
||||
// next/headers cookies() needs to be stubbed — setSessionCookie calls it.
|
||||
vi.mock('next/headers', () => ({
|
||||
cookies: async () => ({
|
||||
set: vi.fn(),
|
||||
get: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
// Import the handlers AFTER mocks are set up
|
||||
import { POST as requestLink } from '@/app/api/auth/request-link/route'
|
||||
import { POST as verifyLink } from '@/app/api/auth/verify/route'
|
||||
|
||||
function makeJsonRequest(url: string, body: unknown, ip = '203.0.113.1'): NextRequest {
|
||||
return new NextRequest(url, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', 'x-forwarded-for': ip },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
function extractToken(url: string): string {
|
||||
const m = url.match(/token=([0-9a-f]+)/)
|
||||
if (!m) throw new Error(`No token in url: ${url}`)
|
||||
return m[1]
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
db.investors = []
|
||||
db.magicLinks = []
|
||||
db.sessions = []
|
||||
sentEmails.length = 0
|
||||
idCounter = 0
|
||||
queryMock.mockClear()
|
||||
})
|
||||
|
||||
describe('Regression: investor can re-request a working magic link after the first is consumed', () => {
|
||||
it('full flow — invite → use → request-link → new link works', async () => {
|
||||
// --- Setup: admin has already invited the investor (simulate the outcome) ---
|
||||
const investorId = 'investor-42'
|
||||
db.investors.push({
|
||||
id: investorId,
|
||||
email: 'vc@example.com',
|
||||
name: 'VC Partner',
|
||||
company: 'Acme Capital',
|
||||
status: 'invited',
|
||||
last_login_at: null,
|
||||
login_count: 0,
|
||||
})
|
||||
db.magicLinks.push({
|
||||
id: 'ml-original',
|
||||
investor_id: investorId,
|
||||
token: 'a'.repeat(96), // original invite token
|
||||
expires_at: new Date(Date.now() + 72 * 60 * 60 * 1000),
|
||||
used_at: null,
|
||||
ip_address: null,
|
||||
user_agent: null,
|
||||
})
|
||||
|
||||
// --- Step 1: investor uses the original invite link ---
|
||||
const firstVerify = await verifyLink(makeJsonRequest('http://localhost/api/auth/verify', { token: 'a'.repeat(96) }))
|
||||
expect(firstVerify.status).toBe(200)
|
||||
const first = db.magicLinks.find(ml => ml.id === 'ml-original')!
|
||||
expect(first.used_at).not.toBeNull()
|
||||
|
||||
// --- Step 2: investor comes back later; clicks the same link → rejected ---
|
||||
const replay = await verifyLink(makeJsonRequest('http://localhost/api/auth/verify', { token: 'a'.repeat(96) }))
|
||||
expect(replay.status).toBe(401)
|
||||
const replayBody = await replay.json()
|
||||
expect(replayBody.error).toMatch(/already been used/i)
|
||||
|
||||
// --- Step 3: investor visits /auth and submits their email ---
|
||||
const reissue = await requestLink(
|
||||
makeJsonRequest('http://localhost/api/auth/request-link', { email: 'vc@example.com' }, '203.0.113.99'),
|
||||
)
|
||||
expect(reissue.status).toBe(200)
|
||||
const reissueBody = await reissue.json()
|
||||
expect(reissueBody.success).toBe(true)
|
||||
|
||||
// --- Step 4: a fresh email was dispatched to the investor ---
|
||||
expect(sentEmails).toHaveLength(1)
|
||||
expect(sentEmails[0].to).toBe('vc@example.com')
|
||||
const newToken = extractToken(sentEmails[0].url)
|
||||
expect(newToken).not.toBe('a'.repeat(96))
|
||||
expect(newToken).toMatch(/^[0-9a-f]{96}$/)
|
||||
|
||||
// A second unused magic link row exists for the same investor
|
||||
const links = db.magicLinks.filter(ml => ml.investor_id === investorId)
|
||||
expect(links).toHaveLength(2)
|
||||
const newLink = links.find(ml => ml.token === newToken)!
|
||||
expect(newLink.used_at).toBeNull()
|
||||
|
||||
// --- Step 5: the new token validates successfully ---
|
||||
const secondVerify = await verifyLink(makeJsonRequest('http://localhost/api/auth/verify', { token: newToken }))
|
||||
expect(secondVerify.status).toBe(200)
|
||||
const secondBody = await secondVerify.json()
|
||||
expect(secondBody.success).toBe(true)
|
||||
expect(secondBody.redirect).toBe('/')
|
||||
|
||||
// And the new link is now used, mirroring the one-time-use contract
|
||||
expect(newLink.used_at).not.toBeNull()
|
||||
})
|
||||
|
||||
it('unknown emails do not create magic links or send email (prevents enumeration & abuse)', async () => {
|
||||
// No investors in the DB
|
||||
const res = await requestLink(
|
||||
makeJsonRequest('http://localhost/api/auth/request-link', { email: 'stranger@example.com' }),
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
// Same generic message as the happy path
|
||||
expect(body.success).toBe(true)
|
||||
expect(body.message).toMatch(/if this email was invited/i)
|
||||
|
||||
expect(sentEmails).toHaveLength(0)
|
||||
expect(db.magicLinks).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('revoked investors cannot self-serve a new link', async () => {
|
||||
db.investors.push({
|
||||
id: 'revoked-1',
|
||||
email: 'gone@example.com',
|
||||
name: null,
|
||||
company: null,
|
||||
status: 'revoked',
|
||||
last_login_at: null,
|
||||
login_count: 0,
|
||||
})
|
||||
|
||||
const res = await requestLink(
|
||||
makeJsonRequest('http://localhost/api/auth/request-link', { email: 'gone@example.com' }),
|
||||
)
|
||||
expect(res.status).toBe(200) // generic success (no info leak)
|
||||
expect(sentEmails).toHaveLength(0)
|
||||
expect(db.magicLinks).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,213 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { NextRequest } from 'next/server'
|
||||
|
||||
// Mock the DB pool before the route is imported
|
||||
const queryMock = vi.fn()
|
||||
vi.mock('@/lib/db', () => ({
|
||||
default: { query: (...args: unknown[]) => queryMock(...args) },
|
||||
}))
|
||||
|
||||
// Mock the email sender so no SMTP is attempted
|
||||
const sendMagicLinkEmailMock = vi.fn().mockResolvedValue(undefined)
|
||||
vi.mock('@/lib/email', () => ({
|
||||
sendMagicLinkEmail: (...args: unknown[]) => sendMagicLinkEmailMock(...args),
|
||||
}))
|
||||
|
||||
// Import after mocks are registered
|
||||
import { POST } from '@/app/api/auth/request-link/route'
|
||||
|
||||
// Unique suffix per test so the rate-limit store (keyed by IP / email) doesn't
|
||||
// bleed across cases — the rate-limiter holds state at module scope.
|
||||
let testId = 0
|
||||
function uniqueIp() {
|
||||
testId++
|
||||
return `10.0.${Math.floor(testId / 250)}.${testId % 250}`
|
||||
}
|
||||
|
||||
function makeRequest(body: unknown, ip = uniqueIp()): NextRequest {
|
||||
return new NextRequest('http://localhost/api/auth/request-link', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-forwarded-for': ip,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
function investorRow(overrides: Partial<{ id: string; email: string; name: string | null; status: string }> = {}) {
|
||||
return {
|
||||
id: overrides.id ?? 'investor-1',
|
||||
email: overrides.email ?? 'invited@example.com',
|
||||
name: overrides.name ?? 'Alice',
|
||||
status: overrides.status ?? 'invited',
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
queryMock.mockReset()
|
||||
sendMagicLinkEmailMock.mockReset()
|
||||
sendMagicLinkEmailMock.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
describe('POST /api/auth/request-link — input validation', () => {
|
||||
it('returns 400 when email is missing', async () => {
|
||||
const res = await POST(makeRequest({}))
|
||||
expect(res.status).toBe(400)
|
||||
const body = await res.json()
|
||||
expect(body.error).toBe('Email required')
|
||||
expect(queryMock).not.toHaveBeenCalled()
|
||||
expect(sendMagicLinkEmailMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 400 when email is not a string', async () => {
|
||||
const res = await POST(makeRequest({ email: 12345 }))
|
||||
expect(res.status).toBe(400)
|
||||
expect(sendMagicLinkEmailMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handles malformed JSON body as missing email (400)', async () => {
|
||||
const req = new NextRequest('http://localhost/api/auth/request-link', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', 'x-forwarded-for': uniqueIp() },
|
||||
body: 'not-json',
|
||||
})
|
||||
const res = await POST(req)
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/auth/request-link — unknown email (enumeration resistance)', () => {
|
||||
it('returns the generic success response without sending email', async () => {
|
||||
// First query: investor lookup → empty rows
|
||||
queryMock.mockResolvedValueOnce({ rows: [] })
|
||||
// Second query: the audit log insert
|
||||
queryMock.mockResolvedValueOnce({ rows: [] })
|
||||
|
||||
const res = await POST(makeRequest({ email: 'unknown@example.com' }))
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.success).toBe(true)
|
||||
expect(body.message).toMatch(/if this email was invited/i)
|
||||
expect(sendMagicLinkEmailMock).not.toHaveBeenCalled()
|
||||
|
||||
// Verify the investor-lookup SQL was issued with the normalized email
|
||||
const [sql, params] = queryMock.mock.calls[0]
|
||||
expect(sql).toMatch(/FROM pitch_investors WHERE email/i)
|
||||
expect(params).toEqual(['unknown@example.com'])
|
||||
})
|
||||
|
||||
it('normalizes email (trim + lowercase) before lookup', async () => {
|
||||
queryMock.mockResolvedValueOnce({ rows: [] })
|
||||
queryMock.mockResolvedValueOnce({ rows: [] })
|
||||
|
||||
await POST(makeRequest({ email: ' Mixed@Example.COM ' }))
|
||||
|
||||
const [, params] = queryMock.mock.calls[0]
|
||||
expect(params).toEqual(['mixed@example.com'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/auth/request-link — known investor', () => {
|
||||
it('creates a new magic link and sends the email with generic response', async () => {
|
||||
// 1st: investor lookup → found
|
||||
queryMock.mockResolvedValueOnce({ rows: [investorRow()] })
|
||||
// 2nd: magic link insert
|
||||
queryMock.mockResolvedValueOnce({ rows: [] })
|
||||
// 3rd: audit log insert
|
||||
queryMock.mockResolvedValueOnce({ rows: [] })
|
||||
|
||||
const res = await POST(makeRequest({ email: 'invited@example.com' }))
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.success).toBe(true)
|
||||
// Response is identical to the unknown-email case (no information leak)
|
||||
expect(body.message).toMatch(/if this email was invited/i)
|
||||
|
||||
// Verify magic link insert
|
||||
const [insertSql, insertParams] = queryMock.mock.calls[1]
|
||||
expect(insertSql).toMatch(/INSERT INTO pitch_magic_links/i)
|
||||
expect(insertParams[0]).toBe('investor-1')
|
||||
expect(insertParams[1]).toMatch(/^[0-9a-f]{96}$/) // 96-char hex token
|
||||
expect(insertParams[2]).toBeInstanceOf(Date)
|
||||
|
||||
// Verify email was sent with the fresh token URL
|
||||
expect(sendMagicLinkEmailMock).toHaveBeenCalledTimes(1)
|
||||
const [emailTo, emailName, magicLinkUrl] = sendMagicLinkEmailMock.mock.calls[0]
|
||||
expect(emailTo).toBe('invited@example.com')
|
||||
expect(emailName).toBe('Alice')
|
||||
expect(magicLinkUrl).toMatch(/\/auth\/verify\?token=[0-9a-f]{96}$/)
|
||||
})
|
||||
|
||||
it('generates a different token on each call (re-invite is always fresh)', async () => {
|
||||
// Call 1
|
||||
queryMock.mockResolvedValueOnce({ rows: [investorRow({ email: 'a@x.com' })] })
|
||||
queryMock.mockResolvedValueOnce({ rows: [] })
|
||||
queryMock.mockResolvedValueOnce({ rows: [] })
|
||||
await POST(makeRequest({ email: 'a@x.com' }))
|
||||
|
||||
// Call 2 — different email to avoid the per-email rate limit
|
||||
queryMock.mockResolvedValueOnce({ rows: [investorRow({ email: 'b@x.com' })] })
|
||||
queryMock.mockResolvedValueOnce({ rows: [] })
|
||||
queryMock.mockResolvedValueOnce({ rows: [] })
|
||||
await POST(makeRequest({ email: 'b@x.com' }))
|
||||
|
||||
const token1 = queryMock.mock.calls[1][1][1]
|
||||
const token2 = queryMock.mock.calls[4][1][1]
|
||||
expect(token1).not.toBe(token2)
|
||||
})
|
||||
|
||||
it('skips email send for a revoked investor (returns generic response)', async () => {
|
||||
queryMock.mockResolvedValueOnce({ rows: [investorRow({ status: 'revoked' })] })
|
||||
queryMock.mockResolvedValueOnce({ rows: [] }) // audit log
|
||||
|
||||
const res = await POST(makeRequest({ email: 'invited@example.com' }))
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.success).toBe(true)
|
||||
expect(sendMagicLinkEmailMock).not.toHaveBeenCalled()
|
||||
|
||||
// Ensure no magic link was inserted
|
||||
const inserts = queryMock.mock.calls.filter(c => /INSERT INTO pitch_magic_links/i.test(c[0]))
|
||||
expect(inserts.length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/auth/request-link — rate limiting', () => {
|
||||
it('throttles after N requests per email and returns generic success (silent throttle)', async () => {
|
||||
const email = `throttle-${Date.now()}@example.com`
|
||||
|
||||
// First 3 requests succeed (RATE_LIMITS.magicLink.limit = 3)
|
||||
for (let i = 0; i < 3; i++) {
|
||||
queryMock.mockResolvedValueOnce({ rows: [investorRow({ email })] })
|
||||
queryMock.mockResolvedValueOnce({ rows: [] }) // magic link insert
|
||||
queryMock.mockResolvedValueOnce({ rows: [] }) // audit log
|
||||
const res = await POST(makeRequest({ email }))
|
||||
expect(res.status).toBe(200)
|
||||
}
|
||||
expect(sendMagicLinkEmailMock).toHaveBeenCalledTimes(3)
|
||||
|
||||
// 4th request is silently throttled — same generic response, no email sent
|
||||
queryMock.mockResolvedValueOnce({ rows: [] }) // audit log only
|
||||
const res4 = await POST(makeRequest({ email }))
|
||||
expect(res4.status).toBe(200)
|
||||
const body4 = await res4.json()
|
||||
expect(body4.success).toBe(true)
|
||||
// Still exactly 3 emails sent — nothing new
|
||||
expect(sendMagicLinkEmailMock).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('throttles with 429 after too many attempts from the same IP', async () => {
|
||||
const ip = '172.31.99.99'
|
||||
// RATE_LIMITS.authVerify.limit = 10 for IP-scoped checks
|
||||
for (let i = 0; i < 10; i++) {
|
||||
queryMock.mockResolvedValueOnce({ rows: [] }) // investor lookup returns empty
|
||||
queryMock.mockResolvedValueOnce({ rows: [] }) // audit
|
||||
const res = await POST(makeRequest({ email: `ip-test-${i}@example.com` }, ip))
|
||||
expect(res.status).toBe(200)
|
||||
}
|
||||
|
||||
const res = await POST(makeRequest({ email: 'final@example.com' }, ip))
|
||||
expect(res.status).toBe(429)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { requireAdmin } from '@/lib/admin-auth'
|
||||
import pool from '@/lib/db'
|
||||
import { computeFinanzplan } from '@/lib/finanzplan/engine'
|
||||
|
||||
/** Admin-only: recompute a Finanzplan scenario. */
|
||||
export async function POST(request: NextRequest) {
|
||||
const guard = await requireAdmin(request)
|
||||
if (guard.kind === 'response') return guard.response
|
||||
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const scenarioId = body.scenarioId || (await pool.query("SELECT id FROM fp_scenarios WHERE is_default = true LIMIT 1")).rows[0]?.id
|
||||
if (!scenarioId) return NextResponse.json({ error: 'No scenario found' }, { status: 404 })
|
||||
|
||||
const result = await computeFinanzplan(pool, scenarioId)
|
||||
return NextResponse.json({ success: true, scenarioId, cash_m60: result.liquiditaet?.endstand?.m60 })
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { requireAdmin } from '@/lib/admin-auth'
|
||||
import pool from '@/lib/db'
|
||||
|
||||
// POST: Import finanzplan data (all fp_* tables) from JSON dump
|
||||
export async function POST(request: NextRequest) {
|
||||
const guard = await requireAdmin(request)
|
||||
if (guard.kind === 'response') return guard.response
|
||||
|
||||
try {
|
||||
const data = await request.json()
|
||||
const results: string[] = []
|
||||
const client = await pool.connect()
|
||||
|
||||
try {
|
||||
await client.query('BEGIN')
|
||||
|
||||
const tables = [
|
||||
'fp_scenarios', 'fp_kunden', 'fp_kunden_summary', 'fp_umsatzerloese',
|
||||
'fp_materialaufwand', 'fp_personalkosten', 'fp_betriebliche_aufwendungen',
|
||||
'fp_investitionen', 'fp_sonst_ertraege', 'fp_liquiditaet', 'fp_guv',
|
||||
]
|
||||
|
||||
for (const table of tables) {
|
||||
const rows = data[table]
|
||||
if (!rows || !Array.isArray(rows) || rows.length === 0) {
|
||||
results.push(`SKIP: ${table} (no data)`)
|
||||
continue
|
||||
}
|
||||
|
||||
// Clear existing data
|
||||
await client.query(`DELETE FROM ${table}`)
|
||||
|
||||
// Insert rows
|
||||
const cols = Object.keys(rows[0])
|
||||
const colNames = cols.join(', ')
|
||||
|
||||
for (const row of rows) {
|
||||
const values = cols.map(c => {
|
||||
const v = row[c]
|
||||
if (v === null || v === undefined) return null
|
||||
if (typeof v === 'object') return JSON.stringify(v)
|
||||
return v
|
||||
})
|
||||
const placeholders = values.map((_, i) => `$${i + 1}`).join(', ')
|
||||
await client.query(`INSERT INTO ${table} (${colNames}) VALUES (${placeholders})`, values)
|
||||
}
|
||||
|
||||
results.push(`OK: ${table} — ${rows.length} rows`)
|
||||
}
|
||||
|
||||
await client.query('COMMIT')
|
||||
return NextResponse.json({ success: true, results })
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK')
|
||||
throw err
|
||||
} finally {
|
||||
client.release()
|
||||
}
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,12 @@ export async function GET(request: NextRequest, ctx: RouteContext) {
|
||||
|
||||
const [investor, sessions, snapshots, audit] = await Promise.all([
|
||||
pool.query(
|
||||
`SELECT id, email, name, company, status, last_login_at, login_count, created_at, updated_at
|
||||
FROM pitch_investors WHERE id = $1`,
|
||||
`SELECT i.id, i.email, i.name, i.company, i.status, i.last_login_at, i.login_count,
|
||||
i.created_at, i.updated_at, i.assigned_version_id,
|
||||
v.name AS version_name, v.status AS version_status
|
||||
FROM pitch_investors i
|
||||
LEFT JOIN pitch_versions v ON v.id = i.assigned_version_id
|
||||
WHERE i.id = $1`,
|
||||
[id],
|
||||
),
|
||||
pool.query(
|
||||
@@ -60,36 +64,58 @@ export async function PATCH(request: NextRequest, ctx: RouteContext) {
|
||||
|
||||
const { id } = await ctx.params
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const { name, company } = body
|
||||
const { name, company, assigned_version_id } = body
|
||||
|
||||
if (name === undefined && company === undefined) {
|
||||
return NextResponse.json({ error: 'name or company required' }, { status: 400 })
|
||||
if (name === undefined && company === undefined && assigned_version_id === undefined) {
|
||||
return NextResponse.json({ error: 'name, company, or assigned_version_id required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const before = await pool.query(
|
||||
`SELECT name, company FROM pitch_investors WHERE id = $1`,
|
||||
`SELECT name, company, assigned_version_id FROM pitch_investors WHERE id = $1`,
|
||||
[id],
|
||||
)
|
||||
if (before.rows.length === 0) {
|
||||
return NextResponse.json({ error: 'Investor not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Validate version exists and is committed (if assigning)
|
||||
if (assigned_version_id !== undefined && assigned_version_id !== null) {
|
||||
const ver = await pool.query(
|
||||
`SELECT id, status FROM pitch_versions WHERE id = $1`,
|
||||
[assigned_version_id],
|
||||
)
|
||||
if (ver.rows.length === 0) {
|
||||
return NextResponse.json({ error: 'Version not found' }, { status: 404 })
|
||||
}
|
||||
if (ver.rows[0].status !== 'committed') {
|
||||
return NextResponse.json({ error: 'Can only assign committed versions' }, { status: 400 })
|
||||
}
|
||||
}
|
||||
|
||||
// Use null to clear version assignment, undefined to leave unchanged
|
||||
const versionValue = assigned_version_id === undefined ? before.rows[0].assigned_version_id : (assigned_version_id || null)
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE pitch_investors SET
|
||||
name = COALESCE($1, name),
|
||||
company = COALESCE($2, company),
|
||||
assigned_version_id = $4,
|
||||
updated_at = NOW()
|
||||
WHERE id = $3
|
||||
RETURNING id, email, name, company, status`,
|
||||
[name ?? null, company ?? null, id],
|
||||
RETURNING id, email, name, company, status, assigned_version_id`,
|
||||
[name ?? null, company ?? null, id, versionValue],
|
||||
)
|
||||
|
||||
const action = assigned_version_id !== undefined && assigned_version_id !== before.rows[0].assigned_version_id
|
||||
? 'investor_version_assigned'
|
||||
: 'investor_edited'
|
||||
|
||||
await logAdminAudit(
|
||||
adminId,
|
||||
'investor_edited',
|
||||
action,
|
||||
{
|
||||
before: before.rows[0],
|
||||
after: { name: rows[0].name, company: rows[0].company },
|
||||
after: { name: rows[0].name, company: rows[0].company, assigned_version_id: rows[0].assigned_version_id },
|
||||
},
|
||||
request,
|
||||
id,
|
||||
|
||||
@@ -8,9 +8,11 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT i.id, i.email, i.name, i.company, i.status, i.last_login_at, i.login_count, i.created_at,
|
||||
i.assigned_version_id, v.name AS version_name,
|
||||
(SELECT COUNT(*) FROM pitch_audit_logs a WHERE a.investor_id = i.id AND a.action = 'slide_viewed') as slides_viewed,
|
||||
(SELECT MAX(a.created_at) FROM pitch_audit_logs a WHERE a.investor_id = i.id) as last_activity
|
||||
FROM pitch_investors i
|
||||
LEFT JOIN pitch_versions v ON v.id = i.assigned_version_id
|
||||
ORDER BY i.created_at DESC`,
|
||||
)
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ export async function POST(request: NextRequest) {
|
||||
const adminId = guard.kind === 'admin' ? guard.admin.id : null
|
||||
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const { email, name, company } = body
|
||||
const { email, name, company, greeting, message, closing } = body
|
||||
|
||||
if (!email || typeof email !== 'string') {
|
||||
return NextResponse.json({ error: 'Email required' }, { status: 400 })
|
||||
@@ -54,7 +54,7 @@ export async function POST(request: NextRequest) {
|
||||
const baseUrl = process.env.PITCH_BASE_URL || 'https://pitch.breakpilot.ai'
|
||||
const magicLinkUrl = `${baseUrl}/auth/verify?token=${token}`
|
||||
|
||||
await sendMagicLinkEmail(normalizedEmail, name || null, magicLinkUrl)
|
||||
await sendMagicLinkEmail(normalizedEmail, name || null, magicLinkUrl, greeting, message, closing)
|
||||
|
||||
await logAdminAudit(
|
||||
adminId,
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { requireAdmin } from '@/lib/admin-auth'
|
||||
import pool from '@/lib/db'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const guard = await requireAdmin(request)
|
||||
if (guard.kind === 'response') return guard.response
|
||||
|
||||
const results: string[] = []
|
||||
|
||||
// Finanzplan tables — the ones missing on production
|
||||
const statements = [
|
||||
`CREATE TABLE IF NOT EXISTS fp_scenarios (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL DEFAULT 'Base Case',
|
||||
description TEXT,
|
||||
is_default BOOLEAN DEFAULT false,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)`,
|
||||
`INSERT INTO fp_scenarios (name, description, is_default)
|
||||
SELECT 'Base Case', 'Basisdaten aus Excel-Import', true
|
||||
WHERE NOT EXISTS (SELECT 1 FROM fp_scenarios WHERE is_default = true)`,
|
||||
`CREATE TABLE IF NOT EXISTS fp_kunden (
|
||||
id SERIAL PRIMARY KEY, scenario_id UUID REFERENCES fp_scenarios(id) ON DELETE CASCADE,
|
||||
segment_name TEXT NOT NULL, segment_index INT NOT NULL, row_label TEXT NOT NULL, row_index INT NOT NULL,
|
||||
percentage NUMERIC(5,3), formula_type TEXT, is_editable BOOLEAN DEFAULT false,
|
||||
values JSONB NOT NULL DEFAULT '{}', excel_row INT, sort_order INT NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS fp_kunden_summary (
|
||||
id SERIAL PRIMARY KEY, scenario_id UUID REFERENCES fp_scenarios(id) ON DELETE CASCADE,
|
||||
row_label TEXT NOT NULL, row_index INT NOT NULL, values JSONB NOT NULL DEFAULT '{}',
|
||||
excel_row INT, sort_order INT NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS fp_umsatzerloese (
|
||||
id SERIAL PRIMARY KEY, scenario_id UUID REFERENCES fp_scenarios(id) ON DELETE CASCADE,
|
||||
section TEXT NOT NULL, row_label TEXT NOT NULL, row_index INT NOT NULL,
|
||||
is_editable BOOLEAN DEFAULT false, values JSONB NOT NULL DEFAULT '{}',
|
||||
excel_row INT, sort_order INT NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS fp_materialaufwand (
|
||||
id SERIAL PRIMARY KEY, scenario_id UUID REFERENCES fp_scenarios(id) ON DELETE CASCADE,
|
||||
section TEXT NOT NULL, row_label TEXT NOT NULL, row_index INT NOT NULL,
|
||||
is_editable BOOLEAN DEFAULT false, values JSONB NOT NULL DEFAULT '{}',
|
||||
excel_row INT, sort_order INT NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS fp_personalkosten (
|
||||
id SERIAL PRIMARY KEY, scenario_id UUID REFERENCES fp_scenarios(id) ON DELETE CASCADE,
|
||||
person_name TEXT NOT NULL, person_nr TEXT, position TEXT,
|
||||
start_date DATE, end_date DATE, brutto_monthly NUMERIC(10,2),
|
||||
annual_raise_pct NUMERIC(5,2) DEFAULT 3.0, ag_sozial_pct NUMERIC(5,2) DEFAULT 20.425,
|
||||
is_editable BOOLEAN DEFAULT true,
|
||||
values_brutto JSONB NOT NULL DEFAULT '{}', values_sozial JSONB NOT NULL DEFAULT '{}',
|
||||
values_total JSONB NOT NULL DEFAULT '{}',
|
||||
excel_row INT, sort_order INT NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS fp_betriebliche_aufwendungen (
|
||||
id SERIAL PRIMARY KEY, scenario_id UUID REFERENCES fp_scenarios(id) ON DELETE CASCADE,
|
||||
category TEXT NOT NULL, row_label TEXT NOT NULL, row_index INT NOT NULL,
|
||||
is_editable BOOLEAN DEFAULT true, is_sum_row BOOLEAN DEFAULT false, formula_desc TEXT,
|
||||
values JSONB NOT NULL DEFAULT '{}', excel_row INT, sort_order INT NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS fp_investitionen (
|
||||
id SERIAL PRIMARY KEY, scenario_id UUID REFERENCES fp_scenarios(id) ON DELETE CASCADE,
|
||||
item_name TEXT NOT NULL, category TEXT, purchase_amount NUMERIC(12,2) NOT NULL,
|
||||
purchase_date DATE, afa_years INT, afa_end_date DATE, is_editable BOOLEAN DEFAULT true,
|
||||
values_invest JSONB NOT NULL DEFAULT '{}', values_afa JSONB NOT NULL DEFAULT '{}',
|
||||
excel_row INT, sort_order INT NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS fp_sonst_ertraege (
|
||||
id SERIAL PRIMARY KEY, scenario_id UUID REFERENCES fp_scenarios(id) ON DELETE CASCADE,
|
||||
category TEXT NOT NULL, row_label TEXT, row_index INT NOT NULL,
|
||||
is_editable BOOLEAN DEFAULT true, is_sum_row BOOLEAN DEFAULT false,
|
||||
values JSONB NOT NULL DEFAULT '{}', excel_row INT, sort_order INT NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS fp_liquiditaet (
|
||||
id SERIAL PRIMARY KEY, scenario_id UUID REFERENCES fp_scenarios(id) ON DELETE CASCADE,
|
||||
row_label TEXT NOT NULL, row_type TEXT NOT NULL,
|
||||
is_editable BOOLEAN DEFAULT false, formula_desc TEXT,
|
||||
values JSONB NOT NULL DEFAULT '{}', excel_row INT, sort_order INT NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS fp_guv (
|
||||
id SERIAL PRIMARY KEY, scenario_id UUID REFERENCES fp_scenarios(id) ON DELETE CASCADE,
|
||||
row_label TEXT NOT NULL, row_index INT NOT NULL,
|
||||
is_sum_row BOOLEAN DEFAULT false, formula_desc TEXT,
|
||||
values JSONB NOT NULL DEFAULT '{}', excel_row INT, sort_order INT NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS fp_cell_overrides (
|
||||
id SERIAL PRIMARY KEY, scenario_id UUID REFERENCES fp_scenarios(id) ON DELETE CASCADE,
|
||||
sheet_name TEXT NOT NULL, row_id INT NOT NULL, month_key TEXT NOT NULL,
|
||||
override_value NUMERIC, created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(scenario_id, sheet_name, row_id, month_key)
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_fp_kunden_scenario ON fp_kunden(scenario_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_fp_kunden_summary_scenario ON fp_kunden_summary(scenario_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_fp_umsatz_scenario ON fp_umsatzerloese(scenario_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_fp_material_scenario ON fp_materialaufwand(scenario_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_fp_personal_scenario ON fp_personalkosten(scenario_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_fp_betrieb_scenario ON fp_betriebliche_aufwendungen(scenario_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_fp_invest_scenario ON fp_investitionen(scenario_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_fp_sonst_scenario ON fp_sonst_ertraege(scenario_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_fp_liquid_scenario ON fp_liquiditaet(scenario_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_fp_guv_scenario ON fp_guv(scenario_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_fp_overrides_lookup ON fp_cell_overrides(scenario_id, sheet_name, row_id)`,
|
||||
]
|
||||
|
||||
for (const sql of statements) {
|
||||
try {
|
||||
await pool.query(sql)
|
||||
const label = sql.substring(0, 60).replace(/\s+/g, ' ')
|
||||
results.push(`OK: ${label}...`)
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
results.push(`ERROR: ${msg}`)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, results })
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import pool from '@/lib/db'
|
||||
import { requireAdmin, logAdminAudit } from '@/lib/admin-auth'
|
||||
|
||||
interface Ctx { params: Promise<{ id: string }> }
|
||||
|
||||
export async function POST(request: NextRequest, ctx: Ctx) {
|
||||
const guard = await requireAdmin(request)
|
||||
if (guard.kind === 'response') return guard.response
|
||||
const adminId = guard.kind === 'admin' ? guard.admin.id : null
|
||||
|
||||
const { id } = await ctx.params
|
||||
|
||||
const ver = await pool.query(`SELECT status, name FROM pitch_versions WHERE id = $1`, [id])
|
||||
if (ver.rows.length === 0) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
if (ver.rows[0].status === 'committed') {
|
||||
return NextResponse.json({ error: 'Already committed' }, { status: 400 })
|
||||
}
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE pitch_versions SET status = 'committed', committed_at = NOW() WHERE id = $1 RETURNING *`,
|
||||
[id],
|
||||
)
|
||||
|
||||
await logAdminAudit(adminId, 'version_committed', {
|
||||
version_id: id,
|
||||
name: rows[0].name,
|
||||
}, request)
|
||||
|
||||
return NextResponse.json({ version: rows[0] })
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import pool from '@/lib/db'
|
||||
import { requireAdmin, logAdminAudit } from '@/lib/admin-auth'
|
||||
import { VERSION_TABLES, VersionTableName } from '@/lib/version-helpers'
|
||||
|
||||
interface Ctx { params: Promise<{ id: string; tableName: string }> }
|
||||
|
||||
export async function GET(request: NextRequest, ctx: Ctx) {
|
||||
const guard = await requireAdmin(request)
|
||||
if (guard.kind === 'response') return guard.response
|
||||
|
||||
const { id, tableName } = await ctx.params
|
||||
|
||||
if (!VERSION_TABLES.includes(tableName as VersionTableName)) {
|
||||
return NextResponse.json({ error: `Invalid table: ${tableName}` }, { status: 400 })
|
||||
}
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT data, updated_at, updated_by FROM pitch_version_data
|
||||
WHERE version_id = $1 AND table_name = $2`,
|
||||
[id, tableName],
|
||||
)
|
||||
|
||||
if (rows.length === 0) {
|
||||
return NextResponse.json({ data: [], updated_at: null })
|
||||
}
|
||||
|
||||
const data = typeof rows[0].data === 'string' ? JSON.parse(rows[0].data) : rows[0].data
|
||||
return NextResponse.json({ data, updated_at: rows[0].updated_at })
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest, ctx: Ctx) {
|
||||
const guard = await requireAdmin(request)
|
||||
if (guard.kind === 'response') return guard.response
|
||||
const adminId = guard.kind === 'admin' ? guard.admin.id : null
|
||||
|
||||
const { id, tableName } = await ctx.params
|
||||
|
||||
if (!VERSION_TABLES.includes(tableName as VersionTableName)) {
|
||||
return NextResponse.json({ error: `Invalid table: ${tableName}` }, { status: 400 })
|
||||
}
|
||||
|
||||
// Verify version is a draft
|
||||
const ver = await pool.query(`SELECT status FROM pitch_versions WHERE id = $1`, [id])
|
||||
if (ver.rows.length === 0) return NextResponse.json({ error: 'Version not found' }, { status: 404 })
|
||||
if (ver.rows[0].status === 'committed') {
|
||||
return NextResponse.json({ error: 'Cannot edit a committed version' }, { status: 400 })
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const { data } = body
|
||||
if (!Array.isArray(data) && typeof data !== 'object') {
|
||||
return NextResponse.json({ error: 'data must be an array or object' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Wrap single-record tables in array for consistency
|
||||
const normalizedData = Array.isArray(data) ? data : [data]
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO pitch_version_data (version_id, table_name, data, updated_by)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (version_id, table_name) DO UPDATE SET
|
||||
data = $3, updated_at = NOW(), updated_by = $4`,
|
||||
[id, tableName, JSON.stringify(normalizedData), adminId],
|
||||
)
|
||||
|
||||
await logAdminAudit(adminId, 'version_data_edited', {
|
||||
version_id: id,
|
||||
table_name: tableName,
|
||||
}, request)
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import pool from '@/lib/db'
|
||||
import { requireAdmin } from '@/lib/admin-auth'
|
||||
import { loadVersionData, VERSION_TABLES } from '@/lib/version-helpers'
|
||||
import { diffTable } from '@/lib/version-diff'
|
||||
|
||||
interface Ctx { params: Promise<{ id: string; otherId: string }> }
|
||||
|
||||
export async function GET(request: NextRequest, ctx: Ctx) {
|
||||
const guard = await requireAdmin(request)
|
||||
if (guard.kind === 'response') return guard.response
|
||||
|
||||
const { id, otherId } = await ctx.params
|
||||
|
||||
// Verify both versions exist
|
||||
const [vA, vB] = await Promise.all([
|
||||
pool.query(`SELECT id, name, status, created_at FROM pitch_versions WHERE id = $1`, [id]),
|
||||
pool.query(`SELECT id, name, status, created_at FROM pitch_versions WHERE id = $1`, [otherId]),
|
||||
])
|
||||
if (vA.rows.length === 0 || vB.rows.length === 0) {
|
||||
return NextResponse.json({ error: 'One or both versions not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const [dataA, dataB] = await Promise.all([
|
||||
loadVersionData(id),
|
||||
loadVersionData(otherId),
|
||||
])
|
||||
|
||||
const diffs = VERSION_TABLES.map(tableName =>
|
||||
diffTable(tableName, dataA[tableName] || [], dataB[tableName] || [])
|
||||
).filter(d => d.hasChanges)
|
||||
|
||||
return NextResponse.json({
|
||||
versionA: vA.rows[0],
|
||||
versionB: vB.rows[0],
|
||||
diffs,
|
||||
total_changes: diffs.reduce((sum, d) => sum + d.rows.filter(r => r.status !== 'unchanged').length, 0),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import pool from '@/lib/db'
|
||||
import { requireAdmin, logAdminAudit } from '@/lib/admin-auth'
|
||||
import { copyVersionData } from '@/lib/version-helpers'
|
||||
|
||||
interface Ctx { params: Promise<{ id: string }> }
|
||||
|
||||
export async function POST(request: NextRequest, ctx: Ctx) {
|
||||
const guard = await requireAdmin(request)
|
||||
if (guard.kind === 'response') return guard.response
|
||||
const adminId = guard.kind === 'admin' ? guard.admin.id : null
|
||||
|
||||
const { id } = await ctx.params
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const name = body.name || ''
|
||||
|
||||
const parent = await pool.query(`SELECT id, name, status FROM pitch_versions WHERE id = $1`, [id])
|
||||
if (parent.rows.length === 0) return NextResponse.json({ error: 'Parent version not found' }, { status: 404 })
|
||||
|
||||
const forkName = name.trim() || `${parent.rows[0].name} (fork)`
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO pitch_versions (name, parent_id, status, created_by)
|
||||
VALUES ($1, $2, 'draft', $3) RETURNING *`,
|
||||
[forkName, id, adminId],
|
||||
)
|
||||
const version = rows[0]
|
||||
|
||||
await copyVersionData(id, version.id, adminId)
|
||||
await logAdminAudit(adminId, 'version_forked', {
|
||||
version_id: version.id,
|
||||
parent_id: id,
|
||||
parent_name: parent.rows[0].name,
|
||||
name: forkName,
|
||||
}, request)
|
||||
|
||||
return NextResponse.json({ version })
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import pool from '@/lib/db'
|
||||
import { requireAdmin, logAdminAudit } from '@/lib/admin-auth'
|
||||
import { loadVersionData } from '@/lib/version-helpers'
|
||||
|
||||
interface Ctx { params: Promise<{ id: string }> }
|
||||
|
||||
export async function GET(request: NextRequest, ctx: Ctx) {
|
||||
const guard = await requireAdmin(request)
|
||||
if (guard.kind === 'response') return guard.response
|
||||
|
||||
const { id } = await ctx.params
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT v.*, a.name AS created_by_name, a.email AS created_by_email,
|
||||
(SELECT COUNT(*)::int FROM pitch_investors i WHERE i.assigned_version_id = v.id) AS assigned_count
|
||||
FROM pitch_versions v
|
||||
LEFT JOIN pitch_admins a ON a.id = v.created_by
|
||||
WHERE v.id = $1`,
|
||||
[id],
|
||||
)
|
||||
if (rows.length === 0) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
|
||||
const data = await loadVersionData(id)
|
||||
|
||||
return NextResponse.json({ version: rows[0], data })
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest, ctx: Ctx) {
|
||||
const guard = await requireAdmin(request)
|
||||
if (guard.kind === 'response') return guard.response
|
||||
const adminId = guard.kind === 'admin' ? guard.admin.id : null
|
||||
|
||||
const { id } = await ctx.params
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const { name, description } = body
|
||||
|
||||
const before = await pool.query(`SELECT name, description, status FROM pitch_versions WHERE id = $1`, [id])
|
||||
if (before.rows.length === 0) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
if (before.rows[0].status === 'committed') {
|
||||
return NextResponse.json({ error: 'Cannot edit a committed version' }, { status: 400 })
|
||||
}
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE pitch_versions SET name = COALESCE($1, name), description = COALESCE($2, description)
|
||||
WHERE id = $3 RETURNING *`,
|
||||
[name ?? null, description ?? null, id],
|
||||
)
|
||||
|
||||
await logAdminAudit(adminId, 'version_edited', {
|
||||
version_id: id,
|
||||
before: { name: before.rows[0].name, description: before.rows[0].description },
|
||||
after: { name: rows[0].name, description: rows[0].description },
|
||||
}, request)
|
||||
|
||||
return NextResponse.json({ version: rows[0] })
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest, ctx: Ctx) {
|
||||
const guard = await requireAdmin(request)
|
||||
if (guard.kind === 'response') return guard.response
|
||||
const adminId = guard.kind === 'admin' ? guard.admin.id : null
|
||||
|
||||
const { id } = await ctx.params
|
||||
|
||||
const ver = await pool.query(`SELECT status, name FROM pitch_versions WHERE id = $1`, [id])
|
||||
if (ver.rows.length === 0) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
|
||||
// Prevent deleting committed versions that have children or assigned investors
|
||||
if (ver.rows[0].status === 'committed') {
|
||||
const children = await pool.query(`SELECT id FROM pitch_versions WHERE parent_id = $1 LIMIT 1`, [id])
|
||||
if (children.rows.length > 0) {
|
||||
return NextResponse.json({ error: 'Cannot delete: has child versions' }, { status: 400 })
|
||||
}
|
||||
const investors = await pool.query(`SELECT id FROM pitch_investors WHERE assigned_version_id = $1 LIMIT 1`, [id])
|
||||
if (investors.rows.length > 0) {
|
||||
return NextResponse.json({ error: 'Cannot delete: assigned to investors' }, { status: 400 })
|
||||
}
|
||||
}
|
||||
|
||||
await pool.query(`DELETE FROM pitch_versions WHERE id = $1`, [id])
|
||||
await logAdminAudit(adminId, 'version_deleted', { version_id: id, name: ver.rows[0].name }, request)
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import pool from '@/lib/db'
|
||||
import { requireAdmin, logAdminAudit } from '@/lib/admin-auth'
|
||||
import { snapshotBaseTables, copyVersionData } from '@/lib/version-helpers'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const guard = await requireAdmin(request)
|
||||
if (guard.kind === 'response') return guard.response
|
||||
|
||||
const { rows } = await pool.query(`
|
||||
SELECT v.*,
|
||||
a.name AS created_by_name, a.email AS created_by_email,
|
||||
(SELECT COUNT(*)::int FROM pitch_investors i WHERE i.assigned_version_id = v.id) AS assigned_count
|
||||
FROM pitch_versions v
|
||||
LEFT JOIN pitch_admins a ON a.id = v.created_by
|
||||
ORDER BY v.created_at DESC
|
||||
`)
|
||||
|
||||
return NextResponse.json({ versions: rows })
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const guard = await requireAdmin(request)
|
||||
if (guard.kind === 'response') return guard.response
|
||||
const adminId = guard.kind === 'admin' ? guard.admin.id : null
|
||||
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const { name, description, parent_id } = body
|
||||
|
||||
if (!name || typeof name !== 'string') {
|
||||
return NextResponse.json({ error: 'name required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Create the version row
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO pitch_versions (name, description, parent_id, status, created_by)
|
||||
VALUES ($1, $2, $3, 'draft', $4) RETURNING *`,
|
||||
[name.trim(), description || null, parent_id || null, adminId],
|
||||
)
|
||||
const version = rows[0]
|
||||
|
||||
// Copy data from parent or snapshot base tables
|
||||
if (parent_id) {
|
||||
await copyVersionData(parent_id, version.id, adminId)
|
||||
} else {
|
||||
await snapshotBaseTables(version.id, adminId)
|
||||
}
|
||||
|
||||
await logAdminAudit(adminId, 'version_created', {
|
||||
version_id: version.id,
|
||||
name: version.name,
|
||||
parent_id: parent_id || null,
|
||||
}, request)
|
||||
|
||||
return NextResponse.json({ version })
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import pool from '@/lib/db'
|
||||
import { generateToken, getClientIp, logAudit } from '@/lib/auth'
|
||||
import { sendMagicLinkEmail } from '@/lib/email'
|
||||
import { checkRateLimit, RATE_LIMITS } from '@/lib/rate-limit'
|
||||
|
||||
// Generic response returned regardless of whether an investor exists, to
|
||||
// prevent email enumeration. The client always sees the same success message.
|
||||
const GENERIC_RESPONSE = {
|
||||
success: true,
|
||||
message: 'If this email was invited, a fresh access link has been sent.',
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const ip = getClientIp(request) || 'unknown'
|
||||
|
||||
// IP-based rate limit to prevent enumeration / abuse
|
||||
const ipRl = checkRateLimit(`request-link-ip:${ip}`, RATE_LIMITS.authVerify)
|
||||
if (!ipRl.allowed) {
|
||||
return NextResponse.json({ error: 'Too many attempts. Try again later.' }, { status: 429 })
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const { email } = body
|
||||
|
||||
if (!email || typeof email !== 'string') {
|
||||
return NextResponse.json({ error: 'Email required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const normalizedEmail = email.toLowerCase().trim()
|
||||
|
||||
// Per-email rate limit (silent — same generic response on throttle so callers
|
||||
// can't distinguish a throttled-but-valid email from an unknown one)
|
||||
const emailRl = checkRateLimit(`magic-link:${normalizedEmail}`, RATE_LIMITS.magicLink)
|
||||
if (!emailRl.allowed) {
|
||||
await logAudit(null, 'request_link_throttled', { email: normalizedEmail }, request)
|
||||
return NextResponse.json(GENERIC_RESPONSE)
|
||||
}
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, email, name, status FROM pitch_investors WHERE email = $1`,
|
||||
[normalizedEmail],
|
||||
)
|
||||
|
||||
if (rows.length === 0) {
|
||||
await logAudit(null, 'request_link_unknown_email', { email: normalizedEmail }, request)
|
||||
return NextResponse.json(GENERIC_RESPONSE)
|
||||
}
|
||||
|
||||
const investor = rows[0]
|
||||
|
||||
if (investor.status === 'revoked') {
|
||||
await logAudit(investor.id, 'request_link_revoked', { email: normalizedEmail }, request)
|
||||
return NextResponse.json(GENERIC_RESPONSE)
|
||||
}
|
||||
|
||||
const token = generateToken()
|
||||
const ttlHours = parseInt(process.env.MAGIC_LINK_TTL_HOURS || '72')
|
||||
const expiresAt = new Date(Date.now() + ttlHours * 60 * 60 * 1000)
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO pitch_magic_links (investor_id, token, expires_at) VALUES ($1, $2, $3)`,
|
||||
[investor.id, token, expiresAt],
|
||||
)
|
||||
|
||||
const baseUrl = process.env.PITCH_BASE_URL || 'https://pitch.breakpilot.ai'
|
||||
const magicLinkUrl = `${baseUrl}/auth/verify?token=${token}`
|
||||
await sendMagicLinkEmail(investor.email, investor.name, magicLinkUrl)
|
||||
|
||||
await logAudit(investor.id, 'request_link_sent', { email: normalizedEmail, expires_at: expiresAt.toISOString() }, request)
|
||||
|
||||
return NextResponse.json(GENERIC_RESPONSE)
|
||||
}
|
||||
@@ -12,11 +12,12 @@ const SLIDE_DISPLAY_NAMES: Record<string, { de: string; en: string }> = {
|
||||
'cover': { de: 'Cover', en: 'Cover' },
|
||||
'problem': { de: 'Das Problem', en: 'The Problem' },
|
||||
'solution': { de: 'Die Lösung', en: 'The Solution' },
|
||||
'usp': { de: 'USP', en: 'USP' },
|
||||
'product': { de: 'Produkte', en: 'Products' },
|
||||
'how-it-works': { de: 'So funktioniert\'s', en: 'How It Works' },
|
||||
'market': { de: 'Markt', en: 'Market' },
|
||||
'business-model': { de: 'Geschäftsmodell', en: 'Business Model' },
|
||||
'traction': { de: 'Traction', en: 'Traction' },
|
||||
'traction': { de: 'Meilensteine', en: 'Milestones' },
|
||||
'competition': { de: 'Wettbewerb', en: 'Competition' },
|
||||
'team': { de: 'Team', en: 'Team' },
|
||||
'financials': { de: 'Finanzen', en: 'Financials' },
|
||||
@@ -47,11 +48,11 @@ Du hast Zugriff auf alle Unternehmensdaten und zitierst immer konkrete Zahlen.
|
||||
- **Zweisprachig**: Antworte in der Sprache, in der die Frage gestellt wird
|
||||
|
||||
## Kernbotschaften (IMMER betonen wenn passend)
|
||||
1. Kern-Produkt: "BreakPilot COMPLAI — DSGVO-konforme KI-Plattform mit 12 Modulen. Kontinuierliche Code-Security und Compliance-Automatisierung. 110 Gesetze und Regularien, 25.000+ Prüfaspekte."
|
||||
1. Kern-Produkt: "BreakPilot COMPLAI — DSGVO-konforme KI-Plattform mit 12 Modulen. Kontinuierliche Code-Security und Compliance-Automatisierung. 380+ Gesetze und Regularien, 25.000+ Prüfaspekte."
|
||||
2. Das Problem: "Unternehmen stehen vor einem strategischen Dilemma: Ohne KI verlieren sie Wettbewerbsfähigkeit. Mit US-KI riskieren sie Datenkontrollverlust. Über 30.000 Unternehmen in DE durch EU-Regulierungen belastet."
|
||||
3. 12 Module: "Code Security (SAST/DAST/SBOM/Pentesting), CE-Software-Risikobeurteilung, Compliance-Dokumente (VVT/DSFA/TOMs), Audit Manager, DSR/Betroffenenrechte, Consent Management, Notfallpläne, Cookie-Generator, Compliance LLM, Academy, Integration in Kundenprozesse, Sichere Kommunikation."
|
||||
4. Code & CE: "Kontinuierlich statt einmal im Jahr. CE-Software-Risikobeurteilung auf Code-Basis schon in der Entwicklung. Findings als Tickets mit Implementierungsvorschlägen."
|
||||
5. EU-Infrastruktur: "BSI-zertifizierte Cloud in Deutschland oder OVH in Frankreich. 100% Datensouveränität. KEINE US-Anbieter. Isolierte Namespaces."
|
||||
5. EU-Infrastruktur: "BSI-zertifizierte Cloud in Deutschland oder Frankreich. 100% Datensouveränität. KEINE US-Anbieter. Isolierte Namespaces."
|
||||
6. Zielgruppen: "Maschinen- und Anlagenbauer, Automobilindustrie, Zulieferer und alle produzierenden Unternehmen."
|
||||
7. Geschäftsmodell: "SaaS, mitarbeiterbasiertes Pricing. Kunden zahlen ~40-50k EUR/Jahr und sparen 50-110k EUR (Pentests 30k, CE-Beurteilungen 20k, Auditmanager 60k+). ROI ab Tag 1."
|
||||
8. Team: "Skalierung 5→10→17→25→35 MA in 4 Jahren. 37% Engineering, 20% Sales, 9% CS, 9% Compliance/Legal, 9% Marketing. Compliance Consultant als erster Hire — Domain-Expertise vor Engineering."
|
||||
@@ -68,9 +69,9 @@ Du hast Zugriff auf alle Unternehmensdaten und zitierst immer konkrete Zahlen.
|
||||
|
||||
## IP-Schutz-Layer (KRITISCH)
|
||||
NIEMALS offenbaren: Exakte Modellnamen, Frameworks, Code-Architektur, Datenbankschema, Sicherheitsdetails, Cloud-Provider.
|
||||
Stattdessen: "Proprietäre KI-Engine", "BSI-zertifizierte EU-Cloud (SysEleven, OVH, Hetzner)", "Isolierte Kunden-Namespaces", "Enterprise-Grade Verschlüsselung".
|
||||
Stattdessen: "Proprietäre KI-Engine", "BSI-zertifizierte EU-Cloud (SysEleven, Hetzner)", "Isolierte Kunden-Namespaces", "Enterprise-Grade Verschlüsselung".
|
||||
|
||||
## Erlaubt: Geschäftsmodell, Preise, Marktdaten, Features, Team, Finanzen, Use of Funds, Hardware-Specs (öffentlich), LLM-Größen (32b/40b/1000b), CE-Risikobeurteilung, Jira-Integration, Meeting-Recorder, Matrix/Jitsi.
|
||||
## Erlaubt: Geschäftsmodell, Preise, Marktdaten, Features, Team, Finanzen, Use of Funds, Hardware-Specs (öffentlich), LLM-Größen (32b/40b/1000b), CE-Risikobeurteilung, Issue-Tracker-Integration, Meeting-Recorder, Matrix/Jitsi.
|
||||
|
||||
## Team-Antworten (WICHTIG)
|
||||
Wenn nach dem Team gefragt wird: IMMER die Namen, Rollen und Expertise der Gründer aus den bereitgestellten Daten nennen. NIEMALS vage Antworten wie "unser Team vereint Expertise" ohne Namen. Zitiere die konkreten Personen aus den Unternehmensdaten.
|
||||
|
||||
@@ -1,24 +1,53 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import pool from '@/lib/db'
|
||||
import { getSessionFromCookie } from '@/lib/auth'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const client = await pool.connect()
|
||||
// Check if investor has an assigned version
|
||||
const session = await getSessionFromCookie()
|
||||
let versionId: string | null = null
|
||||
|
||||
if (session) {
|
||||
const inv = await pool.query(
|
||||
`SELECT assigned_version_id FROM pitch_investors WHERE id = $1`,
|
||||
[session.sub],
|
||||
)
|
||||
versionId = inv.rows[0]?.assigned_version_id || null
|
||||
}
|
||||
|
||||
// If version assigned, load from pitch_version_data
|
||||
if (versionId) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT table_name, data FROM pitch_version_data WHERE version_id = $1`,
|
||||
[versionId],
|
||||
)
|
||||
const map: Record<string, unknown[]> = {}
|
||||
for (const row of rows) {
|
||||
map[row.table_name] = typeof row.data === 'string' ? JSON.parse(row.data) : row.data
|
||||
}
|
||||
return NextResponse.json({
|
||||
company: (map.company || [])[0] || null,
|
||||
team: map.team || [],
|
||||
financials: map.financials || [],
|
||||
market: map.market || [],
|
||||
competitors: map.competitors || [],
|
||||
features: map.features || [],
|
||||
milestones: map.milestones || [],
|
||||
metrics: map.metrics || [],
|
||||
funding: (map.funding || [])[0] || null,
|
||||
products: map.products || [],
|
||||
})
|
||||
}
|
||||
|
||||
// Fallback: read from base tables (backward compatible)
|
||||
const client = await pool.connect()
|
||||
try {
|
||||
const [
|
||||
companyRes,
|
||||
teamRes,
|
||||
financialsRes,
|
||||
marketRes,
|
||||
competitorsRes,
|
||||
featuresRes,
|
||||
milestonesRes,
|
||||
metricsRes,
|
||||
fundingRes,
|
||||
productsRes,
|
||||
companyRes, teamRes, financialsRes, marketRes, competitorsRes,
|
||||
featuresRes, milestonesRes, metricsRes, fundingRes, productsRes,
|
||||
] = await Promise.all([
|
||||
client.query('SELECT * FROM pitch_company LIMIT 1'),
|
||||
client.query('SELECT * FROM pitch_team ORDER BY sort_order'),
|
||||
@@ -49,9 +78,16 @@ export async function GET() {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Database query error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to load pitch data' },
|
||||
{ status: 500 }
|
||||
)
|
||||
// Return minimal stub in dev so the pitch renders without a DB connection
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
return NextResponse.json({
|
||||
company: { name: 'BreakPilot', tagline: '[dev mode — no DB]' },
|
||||
team: [], financials: [], market: [], competitors: [],
|
||||
features: [], milestones: [], metrics: [],
|
||||
funding: { instrument: 'Wandeldarlehen', amount: 500000, valuation_cap: 3000000, currency: 'EUR' },
|
||||
products: [],
|
||||
})
|
||||
}
|
||||
return NextResponse.json({ error: 'Failed to load pitch data' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { finanzplanToFMResults } from '@/lib/finanzplan/adapter'
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { scenarioId, source } = body
|
||||
const { scenarioId, source, force } = body
|
||||
|
||||
// If source=finanzplan, use the Finanzplan engine instead
|
||||
if (source === 'finanzplan') {
|
||||
@@ -28,6 +28,30 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
const client = await pool.connect()
|
||||
try {
|
||||
// Fast path: return cached results if they exist (skip when force=true)
|
||||
const cached = force ? { rows: [] } : await client.query(
|
||||
'SELECT * FROM pitch_fm_results WHERE scenario_id = $1 ORDER BY month',
|
||||
[scenarioId]
|
||||
)
|
||||
if (cached.rows.length > 0) {
|
||||
const results = cached.rows
|
||||
const lastResult = results[results.length - 1]
|
||||
const breakEvenMonth = results.find(r => r.month > 1 && (r.revenue_eur - r.total_costs_eur) >= 0)?.month || null
|
||||
return NextResponse.json({
|
||||
scenario_id: scenarioId,
|
||||
results,
|
||||
summary: {
|
||||
final_arr: lastResult.arr_eur,
|
||||
final_customers: lastResult.total_customers,
|
||||
break_even_month: breakEvenMonth,
|
||||
final_runway: lastResult.runway_months,
|
||||
final_ltv_cac: lastResult.ltv_cac_ratio,
|
||||
peak_burn: Math.max(...results.map((r: Record<string, number>) => r.burn_rate_eur)),
|
||||
total_funding_needed: Math.round(Math.abs(Math.min(...results.map((r: Record<string, number>) => r.cash_balance_eur), 0)) * 100) / 100,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Load assumptions
|
||||
const assumptionsRes = await client.query(
|
||||
'SELECT key, value, value_type FROM pitch_fm_assumptions WHERE scenario_id = $1',
|
||||
@@ -150,19 +174,15 @@ export async function POST(request: NextRequest) {
|
||||
})
|
||||
}
|
||||
|
||||
// Save to DB (upsert)
|
||||
// Save to DB (batch insert — single query instead of 60 individual inserts)
|
||||
await client.query('DELETE FROM pitch_fm_results WHERE scenario_id = $1', [scenarioId])
|
||||
for (const r of results) {
|
||||
await client.query(`
|
||||
INSERT INTO pitch_fm_results (scenario_id, month, year, month_in_year,
|
||||
new_customers, churned_customers, total_customers,
|
||||
mrr_eur, arr_eur, revenue_eur,
|
||||
cogs_eur, personnel_eur, infra_eur, marketing_eur, total_costs_eur,
|
||||
employees_count, gross_margin_pct, burn_rate_eur, runway_months,
|
||||
cac_eur, ltv_eur, ltv_cac_ratio,
|
||||
cash_balance_eur, cumulative_revenue_eur)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24)
|
||||
`, [
|
||||
const cols = 'scenario_id, month, year, month_in_year, new_customers, churned_customers, total_customers, mrr_eur, arr_eur, revenue_eur, cogs_eur, personnel_eur, infra_eur, marketing_eur, total_costs_eur, employees_count, gross_margin_pct, burn_rate_eur, runway_months, cac_eur, ltv_eur, ltv_cac_ratio, cash_balance_eur, cumulative_revenue_eur'
|
||||
const values: unknown[] = []
|
||||
const placeholders: string[] = []
|
||||
results.forEach((r, i) => {
|
||||
const offset = i * 24
|
||||
placeholders.push(`(${Array.from({length: 24}, (_, j) => `$${offset + j + 1}`).join(',')})`)
|
||||
values.push(
|
||||
scenarioId, r.month, r.year, r.month_in_year,
|
||||
r.new_customers, r.churned_customers, r.total_customers,
|
||||
r.mrr_eur, r.arr_eur, r.revenue_eur,
|
||||
@@ -170,8 +190,9 @@ export async function POST(request: NextRequest) {
|
||||
r.employees_count, r.gross_margin_pct, r.burn_rate_eur, r.runway_months,
|
||||
r.cac_eur, r.ltv_eur, r.ltv_cac_ratio,
|
||||
r.cash_balance_eur, r.cumulative_revenue_eur,
|
||||
])
|
||||
}
|
||||
)
|
||||
})
|
||||
await client.query(`INSERT INTO pitch_fm_results (${cols}) VALUES ${placeholders.join(',')}`, values)
|
||||
|
||||
const lastResult = results[results.length - 1]
|
||||
return NextResponse.json({
|
||||
|
||||
@@ -1,32 +1,63 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import pool from '@/lib/db'
|
||||
import { getSessionFromCookie } from '@/lib/auth'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
// GET: Load all scenarios with their assumptions
|
||||
function assembleScenarios(scenarioRows: Record<string, unknown>[], assumptionRows: Record<string, unknown>[]) {
|
||||
return scenarioRows.map(s => ({
|
||||
...s,
|
||||
assumptions: assumptionRows
|
||||
.filter((a: Record<string, unknown>) => a.scenario_id === (s as Record<string, unknown>).id)
|
||||
.map((a: Record<string, unknown>) => ({
|
||||
...a,
|
||||
value: typeof a.value === 'string' ? JSON.parse(a.value as string) : a.value,
|
||||
})),
|
||||
}))
|
||||
}
|
||||
|
||||
// GET: Load all scenarios with their assumptions (version-aware)
|
||||
export async function GET() {
|
||||
try {
|
||||
// Check if investor has an assigned version with FM data
|
||||
const session = await getSessionFromCookie()
|
||||
let versionId: string | null = null
|
||||
|
||||
if (session) {
|
||||
const inv = await pool.query(
|
||||
`SELECT assigned_version_id FROM pitch_investors WHERE id = $1`,
|
||||
[session.sub],
|
||||
)
|
||||
versionId = inv.rows[0]?.assigned_version_id || null
|
||||
}
|
||||
|
||||
if (versionId) {
|
||||
const [scenarioData, assumptionData] = await Promise.all([
|
||||
pool.query(`SELECT data FROM pitch_version_data WHERE version_id = $1 AND table_name = 'fm_scenarios'`, [versionId]),
|
||||
pool.query(`SELECT data FROM pitch_version_data WHERE version_id = $1 AND table_name = 'fm_assumptions'`, [versionId]),
|
||||
])
|
||||
|
||||
if (scenarioData.rows.length > 0) {
|
||||
const scenarios = typeof scenarioData.rows[0].data === 'string'
|
||||
? JSON.parse(scenarioData.rows[0].data) : scenarioData.rows[0].data
|
||||
const assumptions = assumptionData.rows.length > 0
|
||||
? (typeof assumptionData.rows[0].data === 'string'
|
||||
? JSON.parse(assumptionData.rows[0].data) : assumptionData.rows[0].data)
|
||||
: []
|
||||
return NextResponse.json(assembleScenarios(scenarios, assumptions))
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: base tables
|
||||
const client = await pool.connect()
|
||||
try {
|
||||
const scenarios = await client.query(
|
||||
'SELECT * FROM pitch_fm_scenarios ORDER BY is_default DESC, name'
|
||||
)
|
||||
|
||||
const assumptions = await client.query(
|
||||
'SELECT * FROM pitch_fm_assumptions ORDER BY sort_order'
|
||||
)
|
||||
|
||||
const result = scenarios.rows.map(s => ({
|
||||
...s,
|
||||
assumptions: assumptions.rows
|
||||
.filter(a => a.scenario_id === s.id)
|
||||
.map(a => ({
|
||||
...a,
|
||||
value: typeof a.value === 'string' ? JSON.parse(a.value) : a.value,
|
||||
})),
|
||||
}))
|
||||
|
||||
return NextResponse.json(result)
|
||||
return NextResponse.json(assembleScenarios(scenarios.rows, assumptions.rows))
|
||||
} finally {
|
||||
client.release()
|
||||
}
|
||||
|
||||
@@ -39,7 +39,9 @@ export async function GET(
|
||||
query += ' ORDER BY sort_order'
|
||||
|
||||
const { rows } = await pool.query(query, params)
|
||||
return NextResponse.json({ sheet: sheetName, rows })
|
||||
return NextResponse.json({ sheet: sheetName, rows }, {
|
||||
headers: { 'Cache-Control': 'no-store' },
|
||||
})
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 })
|
||||
}
|
||||
|
||||
@@ -25,6 +25,8 @@ export async function GET() {
|
||||
sheets,
|
||||
scenarios: scenarios.rows,
|
||||
months: { start: '2026-01', end: '2030-12', count: 60, founding: '2026-08' },
|
||||
}, {
|
||||
headers: { 'Cache-Control': 'no-store' },
|
||||
})
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 })
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import pool from '@/lib/db'
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
'SELECT key, value, label_de, label_en FROM pitch_pipeline_stats ORDER BY key'
|
||||
)
|
||||
const stats: Record<string, { value: number; label_de: string; label_en: string }> = {}
|
||||
for (const row of rows) {
|
||||
stats[row.key] = { value: Number(row.value), label_de: row.label_de, label_en: row.label_en }
|
||||
}
|
||||
return NextResponse.json(stats)
|
||||
} catch {
|
||||
return NextResponse.json({}, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,20 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const TTS_SERVICE_URL = process.env.TTS_SERVICE_URL || 'http://compliance-tts-service:8095'
|
||||
const LITELLM_URL = process.env.LITELLM_URL || 'https://llm-dev.meghsakha.com'
|
||||
const LITELLM_API_KEY = process.env.LITELLM_API_KEY || ''
|
||||
|
||||
// English via OVH is opt-in (set OVH_TTS_URL_EN). German always uses the
|
||||
// compliance TTS service (Edge TTS de-DE-ConradNeural → Piper fallback).
|
||||
const OVH_EN = process.env.OVH_TTS_URL_EN
|
||||
? {
|
||||
url: process.env.OVH_TTS_URL_EN,
|
||||
voice: process.env.OVH_TTS_VOICE_EN || 'English-US.Female-1',
|
||||
languageCode: 'en-US',
|
||||
}
|
||||
: null
|
||||
|
||||
const SAMPLE_RATE_HZ = parseInt(process.env.OVH_TTS_SAMPLE_RATE || '16000', 10)
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
@@ -11,36 +25,104 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Text is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const res = await fetch(`${TTS_SERVICE_URL}/synthesize-direct`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text, language }),
|
||||
signal: AbortSignal.timeout(30000),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text()
|
||||
console.error('TTS service error:', res.status, errorText)
|
||||
return NextResponse.json(
|
||||
{ error: `TTS service error (${res.status})` },
|
||||
{ status: 502 }
|
||||
)
|
||||
if (language === 'en' && OVH_EN) {
|
||||
return await synthesizeViaOvh(text, OVH_EN)
|
||||
}
|
||||
|
||||
const audioBuffer = await res.arrayBuffer()
|
||||
|
||||
return new NextResponse(audioBuffer, {
|
||||
headers: {
|
||||
'Content-Type': 'audio/mpeg',
|
||||
'Cache-Control': 'public, max-age=86400', // Cache 24h — texts are static
|
||||
'X-TTS-Cache': res.headers.get('X-TTS-Cache') || 'unknown',
|
||||
},
|
||||
})
|
||||
return await synthesizeViaComplianceService(text, language)
|
||||
} catch (error) {
|
||||
console.error('TTS proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'TTS service not reachable' },
|
||||
{ status: 503 }
|
||||
)
|
||||
return NextResponse.json({ error: 'TTS service not reachable' }, { status: 503 })
|
||||
}
|
||||
}
|
||||
|
||||
async function synthesizeViaOvh(
|
||||
text: string,
|
||||
cfg: { url: string; voice: string; languageCode: string },
|
||||
): Promise<NextResponse> {
|
||||
const res = await fetch(cfg.url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
accept: 'application/octet-stream',
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${LITELLM_API_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
encoding: 1, // LINEAR_PCM
|
||||
language_code: cfg.languageCode,
|
||||
sample_rate_hz: SAMPLE_RATE_HZ,
|
||||
text,
|
||||
voice_name: cfg.voice,
|
||||
}),
|
||||
signal: AbortSignal.timeout(30000),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text().catch(() => '')
|
||||
console.error('OVH TTS error:', res.status, errorText.slice(0, 500))
|
||||
return NextResponse.json({ error: `OVH TTS error (${res.status})` }, { status: 502 })
|
||||
}
|
||||
|
||||
const pcm = Buffer.from(await res.arrayBuffer())
|
||||
const wav = pcm.subarray(0, 4).toString('ascii') === 'RIFF' ? pcm : wrapPcmAsWav(pcm, SAMPLE_RATE_HZ)
|
||||
|
||||
return new NextResponse(new Uint8Array(wav), {
|
||||
headers: {
|
||||
'Content-Type': 'audio/wav',
|
||||
'Cache-Control': 'public, max-age=86400',
|
||||
'X-TTS-Source': 'ovh',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function synthesizeViaComplianceService(text: string, language: string): Promise<NextResponse> {
|
||||
const res = await fetch(`${TTS_SERVICE_URL}/synthesize-direct`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text, language }),
|
||||
signal: AbortSignal.timeout(30000),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text().catch(() => '')
|
||||
console.error('TTS service error:', res.status, errorText.slice(0, 500))
|
||||
return NextResponse.json({ error: `TTS service error (${res.status})` }, { status: 502 })
|
||||
}
|
||||
|
||||
const audioBuffer = await res.arrayBuffer()
|
||||
return new NextResponse(audioBuffer, {
|
||||
headers: {
|
||||
'Content-Type': 'audio/mpeg',
|
||||
'Cache-Control': 'public, max-age=86400',
|
||||
'X-TTS-Cache': res.headers.get('X-TTS-Cache') || 'unknown',
|
||||
'X-TTS-Source': 'compliance',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Prepend a minimal 44-byte WAV header to raw 16-bit mono PCM.
|
||||
// Used only for OVH EN if enabled — OVH Riva returns bare PCM samples.
|
||||
function wrapPcmAsWav(pcm: Buffer, sampleRateHz: number): Buffer {
|
||||
const numChannels = 1
|
||||
const bitsPerSample = 16
|
||||
const byteRate = (sampleRateHz * numChannels * bitsPerSample) / 8
|
||||
const blockAlign = (numChannels * bitsPerSample) / 8
|
||||
const dataSize = pcm.length
|
||||
|
||||
const header = Buffer.alloc(44)
|
||||
header.write('RIFF', 0)
|
||||
header.writeUInt32LE(36 + dataSize, 4)
|
||||
header.write('WAVE', 8)
|
||||
header.write('fmt ', 12)
|
||||
header.writeUInt32LE(16, 16)
|
||||
header.writeUInt16LE(1, 20)
|
||||
header.writeUInt16LE(numChannels, 22)
|
||||
header.writeUInt32LE(sampleRateHz, 24)
|
||||
header.writeUInt32LE(byteRate, 28)
|
||||
header.writeUInt16LE(blockAlign, 32)
|
||||
header.writeUInt16LE(bitsPerSample, 34)
|
||||
header.write('data', 36)
|
||||
header.writeUInt32LE(dataSize, 40)
|
||||
|
||||
return Buffer.concat([header, pcm])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import pool from '@/lib/db'
|
||||
import { getAdminFromCookie } from '@/lib/admin-auth'
|
||||
|
||||
interface Ctx { params: Promise<{ versionId: string }> }
|
||||
|
||||
export async function GET(request: NextRequest, ctx: Ctx) {
|
||||
// Admin-only: verify admin session
|
||||
const admin = await getAdminFromCookie()
|
||||
if (!admin) {
|
||||
return NextResponse.json({ error: 'Admin access required for preview' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { versionId } = await ctx.params
|
||||
|
||||
// Load version metadata
|
||||
const ver = await pool.query(
|
||||
`SELECT name, status FROM pitch_versions WHERE id = $1`,
|
||||
[versionId],
|
||||
)
|
||||
if (ver.rows.length === 0) {
|
||||
return NextResponse.json({ error: 'Version not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Load version data
|
||||
const { rows } = await pool.query(
|
||||
`SELECT table_name, data FROM pitch_version_data WHERE version_id = $1`,
|
||||
[versionId],
|
||||
)
|
||||
|
||||
if (rows.length === 0) {
|
||||
return NextResponse.json({ error: 'Version has no data' }, { status: 404 })
|
||||
}
|
||||
|
||||
const map: Record<string, unknown[]> = {}
|
||||
for (const row of rows) {
|
||||
map[row.table_name] = typeof row.data === 'string' ? JSON.parse(row.data) : row.data
|
||||
}
|
||||
|
||||
// Return PitchData format + version metadata
|
||||
return NextResponse.json({
|
||||
company: (map.company || [])[0] || null,
|
||||
team: map.team || [],
|
||||
financials: map.financials || [],
|
||||
market: map.market || [],
|
||||
competitors: map.competitors || [],
|
||||
features: map.features || [],
|
||||
milestones: map.milestones || [],
|
||||
metrics: map.metrics || [],
|
||||
funding: (map.funding || [])[0] || null,
|
||||
products: map.products || [],
|
||||
fm_scenarios: map.fm_scenarios || [],
|
||||
fm_assumptions: map.fm_assumptions || [],
|
||||
_version: { name: ver.rows[0].name, status: ver.rows[0].status },
|
||||
})
|
||||
}
|
||||
@@ -1,8 +1,43 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'framer-motion'
|
||||
import { useState, FormEvent } from 'react'
|
||||
|
||||
type Status = 'idle' | 'submitting' | 'sent' | 'error'
|
||||
|
||||
export default function AuthPage() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [status, setStatus] = useState<Status>('idle')
|
||||
const [message, setMessage] = useState<string | null>(null)
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!email.trim()) return
|
||||
|
||||
setStatus('submitting')
|
||||
setMessage(null)
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth/request-link', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: email.trim() }),
|
||||
})
|
||||
const data = await res.json().catch(() => ({}))
|
||||
|
||||
if (res.ok) {
|
||||
setStatus('sent')
|
||||
setMessage(data.message || 'If this email was invited, a fresh access link has been sent.')
|
||||
} else {
|
||||
setStatus('error')
|
||||
setMessage(data.error || 'Something went wrong. Please try again.')
|
||||
}
|
||||
} catch {
|
||||
setStatus('error')
|
||||
setMessage('Network error. Please try again.')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen flex items-center justify-center bg-[#0a0a1a] relative overflow-hidden">
|
||||
{/* Background gradient */}
|
||||
@@ -35,9 +70,44 @@ export default function AuthPage() {
|
||||
|
||||
<p className="text-white/50 text-sm leading-relaxed mb-6">
|
||||
This interactive pitch deck is available by invitation only.
|
||||
Please check your email for an access link.
|
||||
If you were invited, enter your email below and we'll send you a fresh access link.
|
||||
</p>
|
||||
|
||||
{status === 'sent' ? (
|
||||
<div className="text-left bg-indigo-500/10 border border-indigo-500/20 rounded-lg p-4 mb-5">
|
||||
<p className="text-indigo-200/90 text-sm leading-relaxed">
|
||||
{message}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="text-left mb-5">
|
||||
<label htmlFor="email" className="block text-white/60 text-xs mb-2 uppercase tracking-wide">
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
required
|
||||
autoComplete="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={status === 'submitting'}
|
||||
placeholder="you@example.com"
|
||||
className="w-full bg-white/[0.04] border border-white/[0.08] rounded-lg px-4 py-3 text-white/90 text-sm placeholder:text-white/20 focus:outline-none focus:border-indigo-400/50 focus:bg-white/[0.06] transition-colors disabled:opacity-50"
|
||||
/>
|
||||
{status === 'error' && message && (
|
||||
<p className="mt-2 text-rose-300/80 text-xs">{message}</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={status === 'submitting' || !email.trim()}
|
||||
className="w-full mt-4 bg-gradient-to-br from-indigo-500 to-purple-500 hover:from-indigo-400 hover:to-purple-400 disabled:opacity-40 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg px-4 py-3 transition-all"
|
||||
>
|
||||
{status === 'submitting' ? 'Sending…' : 'Send access link'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="border-t border-white/[0.06] pt-5">
|
||||
<p className="text-white/30 text-xs">
|
||||
Questions? Contact us at{' '}
|
||||
|
||||
@@ -21,6 +21,13 @@ function VerifyContent() {
|
||||
|
||||
async function verify() {
|
||||
try {
|
||||
// If the investor already has a valid session, skip token verification
|
||||
const sessionCheck = await fetch('/api/auth/me')
|
||||
if (sessionCheck.ok) {
|
||||
router.push('/')
|
||||
return
|
||||
}
|
||||
|
||||
const res = await fetch('/api/auth/verify', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&display=swap');
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&display=swap');
|
||||
|
||||
/* === Dark Mode (default) === */
|
||||
:root {
|
||||
--bg-primary: #0a0a1a;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { ArrowLeft, Save } from 'lucide-react'
|
||||
import { ArrowLeft, RefreshCw, Save } from 'lucide-react'
|
||||
|
||||
interface Assumption {
|
||||
id: string
|
||||
@@ -36,6 +36,7 @@ export default function EditScenarioPage() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [edits, setEdits] = useState<Record<string, string>>({})
|
||||
const [savingId, setSavingId] = useState<string | null>(null)
|
||||
const [recomputing, setRecomputing] = useState(false)
|
||||
const [toast, setToast] = useState<string | null>(null)
|
||||
|
||||
function flashToast(msg: string) {
|
||||
@@ -56,6 +57,17 @@ export default function EditScenarioPage() {
|
||||
|
||||
useEffect(() => { if (scenarioId) load() }, [scenarioId])
|
||||
|
||||
async function forceRecompute() {
|
||||
setRecomputing(true)
|
||||
const res = await fetch('/api/financial-model/compute', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ scenarioId, force: true }),
|
||||
})
|
||||
setRecomputing(false)
|
||||
flashToast(res.ok ? 'Recomputed successfully' : 'Recompute failed')
|
||||
}
|
||||
|
||||
function setEdit(id: string, val: string) {
|
||||
setEdits(prev => ({ ...prev, [id]: val }))
|
||||
}
|
||||
@@ -108,17 +120,28 @@ export default function EditScenarioPage() {
|
||||
<ArrowLeft className="w-4 h-4" /> Back to scenarios
|
||||
</Link>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: scenario.color }} />
|
||||
<h1 className="text-2xl font-semibold text-white">{scenario.name}</h1>
|
||||
{scenario.is_default && (
|
||||
<span className="text-[9px] px-1.5 py-0.5 rounded bg-indigo-500/20 text-indigo-300 uppercase font-semibold">
|
||||
Default
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: scenario.color }} />
|
||||
<h1 className="text-2xl font-semibold text-white">{scenario.name}</h1>
|
||||
{scenario.is_default && (
|
||||
<span className="text-[9px] px-1.5 py-0.5 rounded bg-indigo-500/20 text-indigo-300 uppercase font-semibold">
|
||||
Default
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{scenario.description && <p className="text-sm text-white/50">{scenario.description}</p>}
|
||||
</div>
|
||||
{scenario.description && <p className="text-sm text-white/50">{scenario.description}</p>}
|
||||
<button
|
||||
onClick={forceRecompute}
|
||||
disabled={recomputing}
|
||||
className="flex items-center gap-2 text-sm px-3 py-1.5 rounded-lg bg-white/[0.06] hover:bg-white/[0.1] text-white/70 hover:text-white disabled:opacity-40 disabled:cursor-wait transition-colors"
|
||||
title="Clear cache and recompute financial model results"
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${recomputing ? 'animate-spin' : ''}`} />
|
||||
{recomputing ? 'Computing…' : 'Force Recompute'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
@@ -128,9 +151,95 @@ export default function EditScenarioPage() {
|
||||
<div className="space-y-3">
|
||||
{items.map(a => {
|
||||
const isEdited = edits[a.id] !== undefined
|
||||
// Detect arrays of objects for structured editing
|
||||
const isObjectArray = Array.isArray(a.value) && a.value.length > 0 && typeof a.value[0] === 'object' && a.value[0] !== null
|
||||
|
||||
if (isObjectArray) {
|
||||
const rows = isEdited ? (JSON.parse(edits[a.id]) as Record<string, unknown>[]) : (a.value as unknown as Record<string, unknown>[])
|
||||
const cols = Object.keys(rows[0] || {})
|
||||
|
||||
return (
|
||||
<div key={a.id} className="border border-white/[0.06] rounded-xl overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-2.5 bg-white/[0.02]">
|
||||
<div>
|
||||
<span className="text-sm text-white/90">{a.label_en || a.label_de}</span>
|
||||
<span className="text-xs text-white/40 font-mono ml-2">{a.key}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
const newRow: Record<string, unknown> = {}
|
||||
cols.forEach(c => { newRow[c] = typeof rows[0][c] === 'number' ? 0 : '' })
|
||||
const updated = [...rows, newRow]
|
||||
setEdit(a.id, JSON.stringify(updated))
|
||||
}}
|
||||
className="text-[10px] px-2 py-1 rounded bg-white/[0.06] text-white/60 hover:text-white hover:bg-white/[0.1]"
|
||||
>
|
||||
+ Row
|
||||
</button>
|
||||
{isEdited && (
|
||||
<button
|
||||
onClick={() => saveAssumption(a)}
|
||||
disabled={savingId === a.id}
|
||||
className="bg-indigo-500 hover:bg-indigo-600 text-white text-[10px] px-2.5 py-1 rounded flex items-center gap-1 disabled:opacity-50"
|
||||
>
|
||||
<Save className="w-3 h-3" /> Save
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-white/[0.06]">
|
||||
{cols.map(c => (
|
||||
<th key={c} className="text-left py-2 px-3 text-white/40 font-medium uppercase tracking-wider">{c}</th>
|
||||
))}
|
||||
<th className="w-8" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, ri) => (
|
||||
<tr key={ri} className="border-b border-white/[0.04] hover:bg-white/[0.02]">
|
||||
{cols.map(c => (
|
||||
<td key={c} className="py-1.5 px-3">
|
||||
<input
|
||||
type={typeof row[c] === 'number' ? 'number' : 'text'}
|
||||
value={row[c] as string | number}
|
||||
onChange={e => {
|
||||
const updated = rows.map((r, i) => {
|
||||
if (i !== ri) return r
|
||||
const val = typeof r[c] === 'number' ? Number(e.target.value) || 0 : e.target.value
|
||||
return { ...r, [c]: val }
|
||||
})
|
||||
setEdit(a.id, JSON.stringify(updated))
|
||||
}}
|
||||
className="w-full bg-transparent border-b border-transparent hover:border-white/10 focus:border-indigo-500/50 text-white font-mono py-0.5 focus:outline-none"
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
<td className="py-1.5 px-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
const updated = rows.filter((_, i) => i !== ri)
|
||||
setEdit(a.id, JSON.stringify(updated))
|
||||
}}
|
||||
className="text-white/30 hover:text-rose-400 p-1"
|
||||
title="Remove row"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const currentValue = isEdited
|
||||
? edits[a.id]
|
||||
: a.value_type === 'timeseries'
|
||||
: typeof a.value === 'object'
|
||||
? JSON.stringify(a.value)
|
||||
: String(a.value)
|
||||
|
||||
|
||||
@@ -16,6 +16,9 @@ interface InvestorDetail {
|
||||
last_login_at: string | null
|
||||
login_count: number
|
||||
created_at: string
|
||||
assigned_version_id: string | null
|
||||
version_name: string | null
|
||||
version_status: string | null
|
||||
}
|
||||
sessions: Array<{
|
||||
id: string
|
||||
@@ -60,6 +63,11 @@ export default function InvestorDetailPage() {
|
||||
const [company, setCompany] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [toast, setToast] = useState<string | null>(null)
|
||||
const [versions, setVersions] = useState<Array<{ id: string; name: string; status: string }>>([])
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/admin/versions').then(r => r.json()).then(d => setVersions((d.versions || []).filter((v: { status: string }) => v.status === 'committed')))
|
||||
}, [])
|
||||
|
||||
function flashToast(msg: string) {
|
||||
setToast(msg)
|
||||
@@ -236,6 +244,40 @@ export default function InvestorDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Version assignment */}
|
||||
<section className="bg-white/[0.04] border border-white/[0.06] rounded-2xl p-5">
|
||||
<h2 className="text-sm font-semibold text-white mb-3">Pitch Version</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
<select
|
||||
value={inv.assigned_version_id || ''}
|
||||
onChange={async (e) => {
|
||||
const versionId = e.target.value || null
|
||||
setBusy(true)
|
||||
const res = await fetch(`/api/admin/investors/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ assigned_version_id: versionId }),
|
||||
})
|
||||
setBusy(false)
|
||||
if (res.ok) { flashToast('Version updated'); load() }
|
||||
else { flashToast('Update failed') }
|
||||
}}
|
||||
disabled={busy}
|
||||
className="bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/40"
|
||||
>
|
||||
<option value="">Default (base tables)</option>
|
||||
{versions.map(v => (
|
||||
<option key={v.id} value={v.id}>{v.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="text-xs text-white/40">
|
||||
{inv.assigned_version_id
|
||||
? `Investor sees version "${inv.version_name || ''}"`
|
||||
: 'Investor sees default pitch data'}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Audit log for this investor */}
|
||||
<section className="bg-white/[0.04] border border-white/[0.06] rounded-2xl p-5">
|
||||
<h2 className="text-sm font-semibold text-white mb-4">Activity</h2>
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
import { ArrowLeft, Eye, Send } from 'lucide-react'
|
||||
import { DEFAULT_MESSAGE, DEFAULT_CLOSING, getDefaultGreeting } from '@/lib/email-templates'
|
||||
|
||||
export default function NewInvestorPage() {
|
||||
const router = useRouter()
|
||||
const [email, setEmail] = useState('')
|
||||
const [name, setName] = useState('')
|
||||
const [company, setCompany] = useState('')
|
||||
const [greeting, setGreeting] = useState('')
|
||||
const [message, setMessage] = useState(DEFAULT_MESSAGE)
|
||||
const [closing, setClosing] = useState(DEFAULT_CLOSING)
|
||||
const [error, setError] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const effectiveGreeting = greeting || getDefaultGreeting(name || null)
|
||||
const ttl = process.env.NEXT_PUBLIC_MAGIC_LINK_TTL_HOURS || '72'
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
@@ -21,7 +28,14 @@ export default function NewInvestorPage() {
|
||||
const res = await fetch('/api/admin/invite', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, name, company }),
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
name,
|
||||
company,
|
||||
greeting: effectiveGreeting,
|
||||
message,
|
||||
closing,
|
||||
}),
|
||||
})
|
||||
if (res.ok) {
|
||||
router.push('/pitch-admin/investors')
|
||||
@@ -37,8 +51,10 @@ export default function NewInvestorPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const closingHtml = useMemo(() => closing.replace(/\n/g, '<br>'), [closing])
|
||||
|
||||
return (
|
||||
<div className="max-w-xl">
|
||||
<div className="max-w-5xl">
|
||||
<Link
|
||||
href="/pitch-admin/investors"
|
||||
className="inline-flex items-center gap-2 text-sm text-white/50 hover:text-white/80 mb-6"
|
||||
@@ -46,80 +62,203 @@ export default function NewInvestorPage() {
|
||||
<ArrowLeft className="w-4 h-4" /> Back to investors
|
||||
</Link>
|
||||
|
||||
<h1 className="text-2xl font-semibold text-white mb-2">Invite Investor</h1>
|
||||
<h1 className="text-2xl font-semibold text-white mb-2">Investor einladen</h1>
|
||||
<p className="text-sm text-white/50 mb-6">
|
||||
A magic link will be emailed. Single-use, expires in {process.env.NEXT_PUBLIC_MAGIC_LINK_TTL_HOURS || '72'}h.
|
||||
Der Investor erhaelt eine Email mit einem persoenlichen Magic Link (einmalig, verfaellt nach {ttl}h).
|
||||
</p>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="bg-white/[0.04] border border-white/[0.08] rounded-2xl p-6 space-y-4"
|
||||
>
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-xs font-medium text-white/60 uppercase tracking-wider mb-2">
|
||||
Email <span className="text-rose-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2.5 text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/40"
|
||||
placeholder="jane@vc.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-xs font-medium text-white/60 uppercase tracking-wider mb-2">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2.5 text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/40"
|
||||
placeholder="Jane Doe"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="company" className="block text-xs font-medium text-white/60 uppercase tracking-wider mb-2">
|
||||
Company
|
||||
</label>
|
||||
<input
|
||||
id="company"
|
||||
type="text"
|
||||
value={company}
|
||||
onChange={(e) => setCompany(e.target.value)}
|
||||
className="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2.5 text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/40"
|
||||
placeholder="Acme Ventures"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-sm text-rose-300 bg-rose-500/10 border border-rose-500/20 rounded-lg px-3 py-2">
|
||||
{error}
|
||||
<div className="grid lg:grid-cols-2 gap-6">
|
||||
{/* Left: Form */}
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="bg-white/[0.04] border border-white/[0.08] rounded-2xl p-6 space-y-4 self-start"
|
||||
>
|
||||
{/* Email */}
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-xs font-medium text-white/60 uppercase tracking-wider mb-2">
|
||||
Email <span className="text-rose-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2.5 text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/40"
|
||||
placeholder="investor@example.com"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end gap-3 pt-2">
|
||||
<Link
|
||||
href="/pitch-admin/investors"
|
||||
className="text-sm text-white/60 hover:text-white px-4 py-2"
|
||||
>
|
||||
Cancel
|
||||
</Link>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="bg-gradient-to-r from-indigo-500 to-purple-600 hover:from-indigo-600 hover:to-purple-700 text-white text-sm font-medium px-5 py-2.5 rounded-lg disabled:opacity-50 shadow-lg shadow-indigo-500/20"
|
||||
>
|
||||
{submitting ? 'Sending…' : 'Send invite'}
|
||||
</button>
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-xs font-medium text-white/60 uppercase tracking-wider mb-2">
|
||||
Name <span className="text-rose-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
className="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2.5 text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/40"
|
||||
placeholder="Dr. Max Mustermann"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Company (optional) */}
|
||||
<div>
|
||||
<label htmlFor="company" className="block text-xs font-medium text-white/60 uppercase tracking-wider mb-2">
|
||||
Unternehmen <span className="text-white/30 text-[10px] normal-case">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
id="company"
|
||||
type="text"
|
||||
value={company}
|
||||
onChange={(e) => setCompany(e.target.value)}
|
||||
className="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2.5 text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/40"
|
||||
placeholder="Muster Ventures GmbH"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<hr className="border-white/[0.06]" />
|
||||
|
||||
{/* Greeting */}
|
||||
<div>
|
||||
<label htmlFor="greeting" className="block text-xs font-medium text-white/60 uppercase tracking-wider mb-2">
|
||||
Anrede
|
||||
</label>
|
||||
<input
|
||||
id="greeting"
|
||||
type="text"
|
||||
value={greeting}
|
||||
onChange={(e) => setGreeting(e.target.value)}
|
||||
className="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2.5 text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/40"
|
||||
placeholder={getDefaultGreeting(name || null)}
|
||||
/>
|
||||
<p className="text-[10px] text-white/25 mt-1">Leer lassen fuer automatische Anrede basierend auf dem Namen</p>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
<div>
|
||||
<label htmlFor="message" className="block text-xs font-medium text-white/60 uppercase tracking-wider mb-2">
|
||||
Nachricht
|
||||
</label>
|
||||
<textarea
|
||||
id="message"
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
rows={4}
|
||||
className="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2.5 text-white text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500/40 resize-y"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Closing */}
|
||||
<div>
|
||||
<label htmlFor="closing" className="block text-xs font-medium text-white/60 uppercase tracking-wider mb-2">
|
||||
Grussformel
|
||||
</label>
|
||||
<textarea
|
||||
id="closing"
|
||||
value={closing}
|
||||
onChange={(e) => setClosing(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2.5 text-white text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500/40 resize-y"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-sm text-rose-300 bg-rose-500/10 border border-rose-500/20 rounded-lg px-3 py-2">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end gap-3 pt-2">
|
||||
<Link
|
||||
href="/pitch-admin/investors"
|
||||
className="text-sm text-white/60 hover:text-white px-4 py-2"
|
||||
>
|
||||
Abbrechen
|
||||
</Link>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="inline-flex items-center gap-2 bg-gradient-to-r from-indigo-500 to-purple-600 hover:from-indigo-600 hover:to-purple-700 text-white text-sm font-medium px-5 py-2.5 rounded-lg disabled:opacity-50 shadow-lg shadow-indigo-500/20"
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
{submitting ? 'Wird gesendet...' : 'Einladung senden'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Right: Email Preview */}
|
||||
<div className="self-start">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Eye className="w-4 h-4 text-white/40" />
|
||||
<h3 className="text-sm font-semibold text-white/60 uppercase tracking-wider">Email-Vorschau</h3>
|
||||
</div>
|
||||
<div className="bg-[#0a0a1a] border border-white/[0.08] rounded-2xl p-4 overflow-y-auto max-h-[80vh]">
|
||||
{/* Email Card */}
|
||||
<div className="bg-[#111127] border border-indigo-500/20 rounded-xl overflow-hidden">
|
||||
|
||||
{/* Header */}
|
||||
<div className="px-6 pt-6 pb-3">
|
||||
<p className="text-lg font-semibold text-[#e0e0ff]">BreakPilot ComplAI</p>
|
||||
<p className="text-xs text-white/40 mt-1">Investor Pitch Deck</p>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-6 py-3">
|
||||
<p className="text-sm text-white/80 mb-3">{effectiveGreeting},</p>
|
||||
<p className="text-sm text-white/70 leading-relaxed mb-4">{message}</p>
|
||||
|
||||
{/* Magic Link Box */}
|
||||
<div className="bg-indigo-500/[0.08] border border-indigo-500/[0.15] rounded-lg p-3 mb-4">
|
||||
<p className="text-[10px] font-semibold text-indigo-400/80 uppercase tracking-wider mb-1">
|
||||
Ihr persoenlicher Zugangslink
|
||||
</p>
|
||||
<p className="text-xs text-white/50 leading-relaxed">
|
||||
Der untenstehende Link ist einmalig und verfaellt nach {ttl} Stunden. Er gewaehrt Ihnen exklusiven Zugang zu unserem interaktiven Pitch Deck — inklusive KI-Assistent fuer Ihre Fragen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Button Preview */}
|
||||
<div className="text-center mb-3">
|
||||
<span className="inline-block bg-gradient-to-r from-indigo-500 to-purple-600 text-white text-sm font-semibold px-8 py-2.5 rounded-lg">
|
||||
Pitch Deck oeffnen
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] text-white/25 mb-4 break-all">
|
||||
Falls der Button nicht funktioniert: https://pitch.breakpilot.ai/auth/verify?token=...
|
||||
</p>
|
||||
|
||||
{/* Closing */}
|
||||
<p className="text-sm text-white/70 leading-relaxed" dangerouslySetInnerHTML={{ __html: closingHtml }} />
|
||||
</div>
|
||||
|
||||
{/* Legal Footer */}
|
||||
<div className="px-6 py-4 border-t border-white/[0.05]">
|
||||
<p className="text-[9px] font-semibold text-white/30 uppercase tracking-wider mb-1">
|
||||
Vertraulichkeit & Haftungsausschluss
|
||||
</p>
|
||||
<p className="text-[9px] text-white/[0.18] leading-relaxed mb-2">
|
||||
Dieses Pitch Deck ist vertraulich und wurde ausschliesslich fuer den namentlich eingeladenen Empfaenger erstellt. Durch das Oeffnen des Links erklaert sich der Empfaenger einverstanden: (a) Vertrauliche Behandlung, keine Weitergabe an Dritte. (b) Nutzung ausschliesslich zur Bewertung einer Beteiligung. (c) Vertraulichkeitspflicht fuer 3 Jahre.
|
||||
</p>
|
||||
<p className="text-[9px] text-white/[0.18] leading-relaxed mb-3">
|
||||
Kein Angebot, kein Prospekt. Planzahlen ohne Garantie. Totalverlustrisiko. Deutsches Recht, Gerichtsstand Konstanz.
|
||||
</p>
|
||||
<p className="text-[9px] font-semibold text-white/20 uppercase tracking-wider mb-1">
|
||||
Confidentiality & Disclaimer
|
||||
</p>
|
||||
<p className="text-[9px] text-white/[0.13] leading-relaxed">
|
||||
Confidential. Purpose-limited. 3-year obligation. Not an offer. Projections only. Risk of total loss. German law, Konstanz jurisdiction.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ interface Investor {
|
||||
created_at: string
|
||||
slides_viewed: number
|
||||
last_activity: string | null
|
||||
assigned_version_id: string | null
|
||||
version_name: string | null
|
||||
}
|
||||
|
||||
const STATUS_STYLES: Record<string, string> = {
|
||||
@@ -139,6 +141,7 @@ export default function InvestorsPage() {
|
||||
<th className="py-3 px-4 font-medium">Status</th>
|
||||
<th className="py-3 px-4 font-medium text-right">Logins</th>
|
||||
<th className="py-3 px-4 font-medium text-right">Slides</th>
|
||||
<th className="py-3 px-4 font-medium">Version</th>
|
||||
<th className="py-3 px-4 font-medium">Last login</th>
|
||||
<th className="py-3 px-4 font-medium text-right">Actions</th>
|
||||
</tr>
|
||||
@@ -166,6 +169,13 @@ export default function InvestorsPage() {
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right text-white/70 font-mono">{inv.login_count}</td>
|
||||
<td className="py-3 px-4 text-right text-white/70 font-mono">{inv.slides_viewed}</td>
|
||||
<td className="py-3 px-4">
|
||||
{inv.version_name ? (
|
||||
<span className="text-[10px] px-2 py-0.5 rounded bg-purple-500/15 text-purple-300 border border-purple-500/30">{inv.version_name}</span>
|
||||
) : (
|
||||
<span className="text-xs text-white/30">Default</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-white/50 text-xs whitespace-nowrap">
|
||||
{inv.last_login_at ? new Date(inv.last_login_at).toLocaleDateString() : '—'}
|
||||
</td>
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
|
||||
interface FieldDiff {
|
||||
key: string
|
||||
before: unknown
|
||||
after: unknown
|
||||
}
|
||||
|
||||
interface RowDiff {
|
||||
status: 'added' | 'removed' | 'changed' | 'unchanged'
|
||||
fields: FieldDiff[]
|
||||
}
|
||||
|
||||
interface TableDiff {
|
||||
tableName: string
|
||||
rows: RowDiff[]
|
||||
hasChanges: boolean
|
||||
}
|
||||
|
||||
interface DiffData {
|
||||
versionA: { id: string; name: string }
|
||||
versionB: { id: string; name: string }
|
||||
diffs: TableDiff[]
|
||||
total_changes: number
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
added: 'bg-green-500/10 border-green-500/20',
|
||||
removed: 'bg-rose-500/10 border-rose-500/20',
|
||||
changed: 'bg-amber-500/10 border-amber-500/20',
|
||||
}
|
||||
|
||||
export default function DiffPage() {
|
||||
const { id, otherId } = useParams<{ id: string; otherId: string }>()
|
||||
const [data, setData] = useState<DiffData | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (!id || !otherId) return
|
||||
setLoading(true)
|
||||
fetch(`/api/admin/versions/${id}/diff/${otherId}`)
|
||||
.then(r => r.json())
|
||||
.then(setData)
|
||||
.finally(() => setLoading(false))
|
||||
}, [id, otherId])
|
||||
|
||||
if (loading) return <div className="flex items-center justify-center h-64"><div className="w-8 h-8 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin" /></div>
|
||||
if (!data) return <div className="text-rose-400">Failed to load diff</div>
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Link href={`/pitch-admin/versions/${id}`} className="inline-flex items-center gap-2 text-sm text-white/50 hover:text-white/80">
|
||||
<ArrowLeft className="w-4 h-4" /> Back to version
|
||||
</Link>
|
||||
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-white mb-1">Diff</h1>
|
||||
<p className="text-sm text-white/50">
|
||||
<span className="text-indigo-300">{data.versionA.name}</span>
|
||||
{' → '}
|
||||
<span className="text-purple-300">{data.versionB.name}</span>
|
||||
{' — '}{data.total_changes} change{data.total_changes !== 1 ? 's' : ''} across {data.diffs.length} table{data.diffs.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{data.diffs.length === 0 ? (
|
||||
<div className="bg-white/[0.04] border border-white/[0.06] rounded-2xl p-12 text-center text-white/50">
|
||||
No differences found
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{data.diffs.map(table => (
|
||||
<details key={table.tableName} open className="bg-white/[0.04] border border-white/[0.06] rounded-2xl overflow-hidden">
|
||||
<summary className="px-5 py-3 cursor-pointer flex items-center justify-between hover:bg-white/[0.02]">
|
||||
<span className="text-sm font-semibold text-white capitalize">{table.tableName.replace(/_/g, ' ')}</span>
|
||||
<span className="text-xs text-white/40">
|
||||
{table.rows.filter(r => r.status !== 'unchanged').length} change{table.rows.filter(r => r.status !== 'unchanged').length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</summary>
|
||||
<div className="px-5 pb-4 space-y-2">
|
||||
{table.rows.filter(r => r.status !== 'unchanged').map((row, i) => (
|
||||
<div key={i} className={`rounded-lg border p-3 ${STATUS_COLORS[row.status] || ''}`}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className={`text-[9px] px-1.5 py-0.5 rounded uppercase font-semibold ${
|
||||
row.status === 'added' ? 'text-green-300' :
|
||||
row.status === 'removed' ? 'text-rose-300' :
|
||||
'text-amber-300'
|
||||
}`}>{row.status}</span>
|
||||
</div>
|
||||
{row.fields.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{row.fields.map(f => (
|
||||
<div key={f.key} className="text-xs font-mono grid grid-cols-12 gap-2">
|
||||
<span className="col-span-3 text-white/60 truncate">{f.key}</span>
|
||||
<span className="col-span-4 text-rose-300/80 truncate">{JSON.stringify(f.before)}</span>
|
||||
<span className="col-span-1 text-white/30 text-center">→</span>
|
||||
<span className="col-span-4 text-green-300/80 truncate">{JSON.stringify(f.after)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,524 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { ArrowLeft, Lock, Save, GitFork, Eye, Code } from 'lucide-react'
|
||||
import BilingualField from '@/components/pitch-admin/editors/BilingualField'
|
||||
import FormField from '@/components/pitch-admin/editors/FormField'
|
||||
import ArrayField from '@/components/pitch-admin/editors/ArrayField'
|
||||
import RowTable from '@/components/pitch-admin/editors/RowTable'
|
||||
import CardList from '@/components/pitch-admin/editors/CardList'
|
||||
|
||||
const TABLE_LABELS: Record<string, string> = {
|
||||
company: 'Company', team: 'Team', financials: 'Financials', market: 'Market',
|
||||
competitors: 'Competitors', features: 'Features', milestones: 'Milestones',
|
||||
metrics: 'Metrics', funding: 'Funding', products: 'Products',
|
||||
fm_scenarios: 'FM Scenarios', fm_assumptions: 'FM Assumptions',
|
||||
}
|
||||
const TABLE_NAMES = Object.keys(TABLE_LABELS)
|
||||
|
||||
interface Version {
|
||||
id: string; name: string; description: string | null
|
||||
status: 'draft' | 'committed'; parent_id: string | null; committed_at: string | null
|
||||
}
|
||||
|
||||
type R = Record<string, unknown>
|
||||
|
||||
export default function VersionEditorPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const router = useRouter()
|
||||
const [version, setVersion] = useState<Version | null>(null)
|
||||
const [allData, setAllData] = useState<Record<string, unknown[]>>({})
|
||||
const [activeTab, setActiveTab] = useState('company')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [dirty, setDirty] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [jsonMode, setJsonMode] = useState(false)
|
||||
const [jsonText, setJsonText] = useState('')
|
||||
const [toast, setToast] = useState<string | null>(null)
|
||||
|
||||
function flashToast(msg: string) { setToast(msg); setTimeout(() => setToast(null), 3000) }
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
const res = await fetch(`/api/admin/versions/${id}`)
|
||||
if (res.ok) { const d = await res.json(); setVersion(d.version); setAllData(d.data) }
|
||||
setLoading(false)
|
||||
}, [id])
|
||||
|
||||
useEffect(() => { if (id) load() }, [id, load])
|
||||
|
||||
// Sync JSON text when switching tabs or toggling JSON mode
|
||||
useEffect(() => {
|
||||
if (jsonMode) setJsonText(JSON.stringify(allData[activeTab] || [], null, 2))
|
||||
}, [activeTab, jsonMode, allData])
|
||||
|
||||
function updateData(newData: unknown[]) {
|
||||
setAllData(prev => ({ ...prev, [activeTab]: newData }))
|
||||
setDirty(true)
|
||||
}
|
||||
|
||||
function updateRecord(index: number, key: string, value: unknown) {
|
||||
const arr = [...(allData[activeTab] as R[] || [])]
|
||||
arr[index] = { ...arr[index], [key]: value }
|
||||
updateData(arr)
|
||||
}
|
||||
|
||||
// For single-record tables (company, funding)
|
||||
function updateSingle(key: string, value: unknown) { updateRecord(0, key, value) }
|
||||
|
||||
async function saveTable() {
|
||||
let data: unknown
|
||||
if (jsonMode) {
|
||||
try { data = JSON.parse(jsonText) } catch { flashToast('Invalid JSON'); return }
|
||||
} else {
|
||||
data = allData[activeTab]
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
const res = await fetch(`/api/admin/versions/${id}/data/${activeTab}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ data }),
|
||||
})
|
||||
setSaving(false)
|
||||
if (res.ok) {
|
||||
setDirty(false)
|
||||
if (jsonMode) setAllData(prev => ({ ...prev, [activeTab]: Array.isArray(data) ? data : [data] }))
|
||||
flashToast('Saved')
|
||||
} else { const d = await res.json().catch(() => ({})); flashToast(d.error || 'Save failed') }
|
||||
}
|
||||
|
||||
async function commitVersion() {
|
||||
if (!confirm('Commit this version? It becomes immutable.')) return
|
||||
const res = await fetch(`/api/admin/versions/${id}/commit`, { method: 'POST' })
|
||||
if (res.ok) { flashToast('Committed'); load() }
|
||||
else { const d = await res.json().catch(() => ({})); flashToast(d.error || 'Failed') }
|
||||
}
|
||||
|
||||
async function forkVersion() {
|
||||
const name = prompt('Name for the new draft:')
|
||||
if (!name) return
|
||||
const res = await fetch(`/api/admin/versions/${id}/fork`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }),
|
||||
})
|
||||
if (res.ok) { const d = await res.json(); router.push(`/pitch-admin/versions/${d.version.id}`) }
|
||||
else { const d = await res.json().catch(() => ({})); flashToast(d.error || 'Failed') }
|
||||
}
|
||||
|
||||
if (loading) return <div className="flex items-center justify-center h-64"><div className="w-8 h-8 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin" /></div>
|
||||
if (!version) return <div className="text-rose-400">Version not found</div>
|
||||
|
||||
const isDraft = version.status === 'draft'
|
||||
const data = allData[activeTab] || []
|
||||
const single = (data as R[])[0] || {} as R
|
||||
|
||||
function renderEditor() {
|
||||
if (jsonMode) {
|
||||
return (
|
||||
<textarea
|
||||
value={jsonText}
|
||||
onChange={e => { setJsonText(e.target.value); setDirty(true) }}
|
||||
readOnly={!isDraft}
|
||||
className="w-full bg-transparent text-white/90 font-mono text-xs p-4 focus:outline-none resize-none"
|
||||
style={{ minHeight: '400px' }}
|
||||
spellCheck={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
switch (activeTab) {
|
||||
case 'company':
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<FormField label="Company Name" value={single.name as string || ''} onChange={v => updateSingle('name', v)} />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField label="Legal Form" value={single.legal_form as string || ''} onChange={v => updateSingle('legal_form', v)} placeholder="GmbH" />
|
||||
<FormField label="Founding Date" value={single.founding_date as string || ''} onChange={v => updateSingle('founding_date', v)} type="date" />
|
||||
</div>
|
||||
<BilingualField label="Tagline" valueDe={single.tagline_de as string || ''} valueEn={single.tagline_en as string || ''} onChangeDe={v => updateSingle('tagline_de', v)} onChangeEn={v => updateSingle('tagline_en', v)} />
|
||||
<BilingualField label="Mission" valueDe={single.mission_de as string || ''} valueEn={single.mission_en as string || ''} onChangeDe={v => updateSingle('mission_de', v)} onChangeEn={v => updateSingle('mission_en', v)} multiline />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField label="Website" value={single.website as string || ''} onChange={v => updateSingle('website', v)} type="url" />
|
||||
<FormField label="HQ City" value={single.hq_city as string || ''} onChange={v => updateSingle('hq_city', v)} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'team':
|
||||
return (
|
||||
<div className="p-4">
|
||||
<CardList
|
||||
items={data as R[]}
|
||||
onChange={updateData}
|
||||
titleKey="name"
|
||||
subtitleKey="role_en"
|
||||
addLabel="Add team member"
|
||||
renderCard={(item, update) => (
|
||||
<div className="space-y-3">
|
||||
<FormField label="Name" value={item.name as string || ''} onChange={v => update('name', v)} />
|
||||
<BilingualField label="Role" valueDe={item.role_de as string || ''} valueEn={item.role_en as string || ''} onChangeDe={v => update('role_de', v)} onChangeEn={v => update('role_en', v)} />
|
||||
<BilingualField label="Bio" valueDe={item.bio_de as string || ''} valueEn={item.bio_en as string || ''} onChangeDe={v => update('bio_de', v)} onChangeEn={v => update('bio_en', v)} multiline />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField label="Equity %" value={item.equity_pct as number || 0} onChange={v => update('equity_pct', v)} type="number" />
|
||||
<FormField label="LinkedIn" value={item.linkedin_url as string || ''} onChange={v => update('linkedin_url', v)} type="url" />
|
||||
</div>
|
||||
<ArrayField label="Expertise" values={(item.expertise as string[]) || []} onChange={v => update('expertise', v)} />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'financials':
|
||||
return (
|
||||
<div className="p-4">
|
||||
<RowTable
|
||||
rows={data as R[]}
|
||||
onChange={updateData}
|
||||
columns={[
|
||||
{ key: 'year', label: 'Year', type: 'number' },
|
||||
{ key: 'revenue_eur', label: 'Revenue (EUR)', type: 'number' },
|
||||
{ key: 'costs_eur', label: 'Costs (EUR)', type: 'number' },
|
||||
{ key: 'mrr_eur', label: 'MRR (EUR)', type: 'number' },
|
||||
{ key: 'arr_eur', label: 'ARR (EUR)', type: 'number' },
|
||||
{ key: 'customers_count', label: 'Customers', type: 'number' },
|
||||
{ key: 'employees_count', label: 'Employees', type: 'number' },
|
||||
{ key: 'burn_rate_eur', label: 'Burn (EUR)', type: 'number' },
|
||||
]}
|
||||
addLabel="Add year"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'market':
|
||||
return (
|
||||
<div className="p-4">
|
||||
<RowTable
|
||||
rows={data as R[]}
|
||||
onChange={updateData}
|
||||
columns={[
|
||||
{ key: 'market_segment', label: 'Segment' },
|
||||
{ key: 'label', label: 'Label' },
|
||||
{ key: 'value_eur', label: 'Value (EUR)', type: 'number' },
|
||||
{ key: 'growth_rate_pct', label: 'Growth %', type: 'number' },
|
||||
{ key: 'source', label: 'Source' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'competitors':
|
||||
return (
|
||||
<div className="p-4">
|
||||
<CardList
|
||||
items={data as R[]}
|
||||
onChange={updateData}
|
||||
titleKey="name"
|
||||
subtitleKey="website"
|
||||
addLabel="Add competitor"
|
||||
renderCard={(item, update) => (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField label="Name" value={item.name as string || ''} onChange={v => update('name', v)} />
|
||||
<FormField label="Website" value={item.website as string || ''} onChange={v => update('website', v)} type="url" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField label="Customers" value={item.customers_count as number || 0} onChange={v => update('customers_count', v)} type="number" />
|
||||
<FormField label="Pricing Range" value={item.pricing_range as string || ''} onChange={v => update('pricing_range', v)} />
|
||||
</div>
|
||||
<ArrayField label="Strengths" values={(item.strengths as string[]) || []} onChange={v => update('strengths', v)} />
|
||||
<ArrayField label="Weaknesses" values={(item.weaknesses as string[]) || []} onChange={v => update('weaknesses', v)} />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'features':
|
||||
return (
|
||||
<div className="p-4">
|
||||
<CardList
|
||||
items={data as R[]}
|
||||
onChange={updateData}
|
||||
titleKey="feature_name_en"
|
||||
subtitleKey="category"
|
||||
addLabel="Add feature"
|
||||
renderCard={(item, update) => (
|
||||
<div className="space-y-3">
|
||||
<BilingualField label="Feature Name" valueDe={item.feature_name_de as string || ''} valueEn={item.feature_name_en as string || ''} onChangeDe={v => update('feature_name_de', v)} onChangeEn={v => update('feature_name_en', v)} />
|
||||
<FormField label="Category" value={item.category as string || ''} onChange={v => update('category', v)} />
|
||||
<div className="grid grid-cols-5 gap-3">
|
||||
<FormField label="BreakPilot" value={!!item.breakpilot} onChange={v => update('breakpilot', v)} type="checkbox" />
|
||||
<FormField label="Proliance" value={!!item.proliance} onChange={v => update('proliance', v)} type="checkbox" />
|
||||
<FormField label="DataGuard" value={!!item.dataguard} onChange={v => update('dataguard', v)} type="checkbox" />
|
||||
<FormField label="heyData" value={!!item.heydata} onChange={v => update('heydata', v)} type="checkbox" />
|
||||
<FormField label="Differentiator" value={!!item.is_differentiator} onChange={v => update('is_differentiator', v)} type="checkbox" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'milestones':
|
||||
return (
|
||||
<div className="p-4">
|
||||
<CardList
|
||||
items={data as R[]}
|
||||
onChange={updateData}
|
||||
titleKey="title_en"
|
||||
subtitleKey="milestone_date"
|
||||
addLabel="Add milestone"
|
||||
renderCard={(item, update) => (
|
||||
<div className="space-y-3">
|
||||
<BilingualField label="Title" valueDe={item.title_de as string || ''} valueEn={item.title_en as string || ''} onChangeDe={v => update('title_de', v)} onChangeEn={v => update('title_en', v)} />
|
||||
<BilingualField label="Description" valueDe={item.description_de as string || ''} valueEn={item.description_en as string || ''} onChangeDe={v => update('description_de', v)} onChangeEn={v => update('description_en', v)} multiline />
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<FormField label="Date" value={item.milestone_date as string || ''} onChange={v => update('milestone_date', v)} />
|
||||
<FormField label="Status" value={item.status as string || ''} onChange={v => update('status', v)} type="select" options={[
|
||||
{ value: 'completed', label: 'Completed' }, { value: 'in_progress', label: 'In Progress' }, { value: 'planned', label: 'Planned' },
|
||||
]} />
|
||||
<FormField label="Category" value={item.category as string || ''} onChange={v => update('category', v)} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'metrics':
|
||||
return (
|
||||
<div className="p-4">
|
||||
<CardList
|
||||
items={data as R[]}
|
||||
onChange={updateData}
|
||||
titleKey="metric_name"
|
||||
subtitleKey="value"
|
||||
addLabel="Add metric"
|
||||
renderCard={(item, update) => (
|
||||
<div className="space-y-3">
|
||||
<FormField label="Metric Key" value={item.metric_name as string || ''} onChange={v => update('metric_name', v)} />
|
||||
<BilingualField label="Label" valueDe={item.label_de as string || ''} valueEn={item.label_en as string || ''} onChangeDe={v => update('label_de', v)} onChangeEn={v => update('label_en', v)} />
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<FormField label="Value" value={item.value as string || ''} onChange={v => update('value', v)} />
|
||||
<FormField label="Unit" value={item.unit as string || ''} onChange={v => update('unit', v)} />
|
||||
<FormField label="Is Live" value={!!item.is_live} onChange={v => update('is_live', v)} type="checkbox" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'funding':
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<FormField label="Round Name" value={single.round_name as string || ''} onChange={v => updateSingle('round_name', v)} />
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<FormField label="Amount (EUR)" value={single.amount_eur as number || 0} onChange={v => updateSingle('amount_eur', v)} type="number" />
|
||||
<FormField label="Instrument" value={single.instrument as string || ''} onChange={v => updateSingle('instrument', v)} />
|
||||
<FormField label="Target Date" value={single.target_date as string || ''} onChange={v => updateSingle('target_date', v)} type="date" />
|
||||
</div>
|
||||
<FormField label="Status" value={single.status as string || ''} onChange={v => updateSingle('status', v)} type="select" options={[
|
||||
{ value: 'planned', label: 'Planned' }, { value: 'in_progress', label: 'In Progress' }, { value: 'completed', label: 'Completed' },
|
||||
]} />
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-white/60 uppercase tracking-wider mb-2">Use of Funds</label>
|
||||
<RowTable
|
||||
rows={(single.use_of_funds as R[]) || []}
|
||||
onChange={v => updateSingle('use_of_funds', v)}
|
||||
columns={[
|
||||
{ key: 'category', label: 'Category' },
|
||||
{ key: 'percentage', label: '%', type: 'number' },
|
||||
{ key: 'label_de', label: 'Label DE' },
|
||||
{ key: 'label_en', label: 'Label EN' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'products':
|
||||
return (
|
||||
<div className="p-4">
|
||||
<CardList
|
||||
items={data as R[]}
|
||||
onChange={updateData}
|
||||
titleKey="name"
|
||||
subtitleKey="hardware"
|
||||
addLabel="Add product"
|
||||
renderCard={(item, update) => (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField label="Name" value={item.name as string || ''} onChange={v => update('name', v)} />
|
||||
<FormField label="Hardware" value={item.hardware as string || ''} onChange={v => update('hardware', v)} />
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<FormField label="HW Cost (EUR)" value={item.hardware_cost_eur as number || 0} onChange={v => update('hardware_cost_eur', v)} type="number" />
|
||||
<FormField label="Monthly Price (EUR)" value={item.monthly_price_eur as number || 0} onChange={v => update('monthly_price_eur', v)} type="number" />
|
||||
<FormField label="Operating Cost (EUR)" value={item.operating_cost_eur as number || 0} onChange={v => update('operating_cost_eur', v)} type="number" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField label="LLM Model" value={item.llm_model as string || ''} onChange={v => update('llm_model', v)} />
|
||||
<FormField label="LLM Size" value={item.llm_size as string || ''} onChange={v => update('llm_size', v)} />
|
||||
</div>
|
||||
<BilingualField label="LLM Capability" valueDe={item.llm_capability_de as string || ''} valueEn={item.llm_capability_en as string || ''} onChangeDe={v => update('llm_capability_de', v)} onChangeEn={v => update('llm_capability_en', v)} multiline />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<ArrayField label="Features (DE)" values={(item.features_de as string[]) || []} onChange={v => update('features_de', v)} />
|
||||
<ArrayField label="Features (EN)" values={(item.features_en as string[]) || []} onChange={v => update('features_en', v)} />
|
||||
</div>
|
||||
<FormField label="Popular" value={!!item.is_popular} onChange={v => update('is_popular', v)} type="checkbox" />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'fm_scenarios':
|
||||
return (
|
||||
<div className="p-4">
|
||||
<CardList
|
||||
items={data as R[]}
|
||||
onChange={updateData}
|
||||
titleKey="name"
|
||||
subtitleKey="description"
|
||||
addLabel="Add scenario"
|
||||
renderCard={(item, update) => (
|
||||
<div className="space-y-3">
|
||||
<FormField label="Name" value={item.name as string || ''} onChange={v => update('name', v)} />
|
||||
<FormField label="Description" value={item.description as string || ''} onChange={v => update('description', v)} />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField label="Color" value={item.color as string || '#6366f1'} onChange={v => update('color', v)} type="color" />
|
||||
<FormField label="Default" value={!!item.is_default} onChange={v => update('is_default', v)} type="checkbox" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'fm_assumptions':
|
||||
// Reuse the inline table approach from the FM editor (already works well for this)
|
||||
return (
|
||||
<div className="p-4">
|
||||
<RowTable
|
||||
rows={data as R[]}
|
||||
onChange={updateData}
|
||||
columns={[
|
||||
{ key: 'key', label: 'Key' },
|
||||
{ key: 'label_de', label: 'Label DE' },
|
||||
{ key: 'label_en', label: 'Label EN' },
|
||||
{ key: 'category', label: 'Category' },
|
||||
{ key: 'unit', label: 'Unit' },
|
||||
]}
|
||||
addLabel="Add assumption"
|
||||
/>
|
||||
<p className="text-[10px] text-white/30 mt-2">Note: values, min/max/step are best edited via "Edit as JSON" mode for complex types.</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
default:
|
||||
return <div className="p-4 text-white/40">No editor for this table</div>
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Link href="/pitch-admin/versions" className="inline-flex items-center gap-2 text-sm text-white/50 hover:text-white/80">
|
||||
<ArrowLeft className="w-4 h-4" /> Back to versions
|
||||
</Link>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h1 className="text-2xl font-semibold text-white">{version.name}</h1>
|
||||
<span className={`text-[9px] px-2 py-0.5 rounded-full border uppercase font-semibold ${
|
||||
isDraft ? 'bg-amber-500/15 text-amber-300 border-amber-500/30' : 'bg-green-500/15 text-green-300 border-green-500/30'
|
||||
}`}>{version.status}</span>
|
||||
</div>
|
||||
{version.description && <p className="text-sm text-white/50">{version.description}</p>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href={`/pitch-preview/${id}`}
|
||||
target="_blank"
|
||||
className="bg-white/[0.06] hover:bg-white/[0.1] text-white text-sm px-4 py-2 rounded-lg flex items-center gap-2"
|
||||
>
|
||||
<Eye className="w-4 h-4" /> Preview
|
||||
</Link>
|
||||
{isDraft && (
|
||||
<button onClick={commitVersion} className="bg-green-500/15 hover:bg-green-500/25 text-green-300 text-sm px-4 py-2 rounded-lg flex items-center gap-2">
|
||||
<Lock className="w-4 h-4" /> Commit
|
||||
</button>
|
||||
)}
|
||||
<button onClick={forkVersion} className="bg-indigo-500/15 hover:bg-indigo-500/25 text-indigo-300 text-sm px-4 py-2 rounded-lg flex items-center gap-2">
|
||||
<GitFork className="w-4 h-4" /> Fork
|
||||
</button>
|
||||
{version.parent_id && (
|
||||
<Link href={`/pitch-admin/versions/${id}/diff/${version.parent_id}`} className="bg-white/[0.06] hover:bg-white/[0.1] text-white text-sm px-4 py-2 rounded-lg">
|
||||
Diff
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 overflow-x-auto pb-1">
|
||||
{TABLE_NAMES.map(t => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => { if (dirty && !confirm('Discard unsaved changes?')) return; setActiveTab(t); setDirty(false); setJsonMode(false) }}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs whitespace-nowrap transition-colors ${
|
||||
activeTab === t
|
||||
? 'bg-indigo-500/20 text-indigo-300 border border-indigo-500/30'
|
||||
: 'bg-white/[0.04] text-white/40 border border-transparent hover:text-white/60'
|
||||
}`}
|
||||
>
|
||||
{TABLE_LABELS[t]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Editor */}
|
||||
<div className="bg-white/[0.04] border border-white/[0.06] rounded-2xl overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-white/[0.06]">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-semibold text-white">{TABLE_LABELS[activeTab]}</span>
|
||||
{dirty && <span className="text-[9px] px-2 py-0.5 rounded bg-amber-500/20 text-amber-300">Unsaved</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setJsonMode(!jsonMode)}
|
||||
className={`text-[10px] px-2 py-1 rounded flex items-center gap-1 transition-colors ${
|
||||
jsonMode ? 'bg-indigo-500/20 text-indigo-300' : 'bg-white/[0.04] text-white/40 hover:text-white/60'
|
||||
}`}
|
||||
>
|
||||
<Code className="w-3 h-3" /> {jsonMode ? 'Form' : 'JSON'}
|
||||
</button>
|
||||
{isDraft && (
|
||||
<button
|
||||
onClick={saveTable}
|
||||
disabled={saving || !dirty}
|
||||
className="bg-indigo-500 hover:bg-indigo-600 text-white text-sm font-medium px-4 py-1.5 rounded-lg flex items-center gap-2 disabled:opacity-30"
|
||||
>
|
||||
<Save className="w-3.5 h-3.5" /> {saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{renderEditor()}
|
||||
</div>
|
||||
|
||||
{!isDraft && <p className="text-xs text-white/30 text-center">Committed — read-only. Fork to edit.</p>}
|
||||
|
||||
{toast && (
|
||||
<div className="fixed bottom-6 right-6 bg-indigo-500/20 border border-indigo-500/40 text-indigo-200 text-sm px-4 py-2 rounded-lg backdrop-blur-sm z-50">
|
||||
{toast}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
|
||||
interface VersionOption {
|
||||
id: string
|
||||
name: string
|
||||
status: string
|
||||
}
|
||||
|
||||
export default function NewVersionPage() {
|
||||
const router = useRouter()
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [parentId, setParentId] = useState<string>('')
|
||||
const [versions, setVersions] = useState<VersionOption[]>([])
|
||||
const [error, setError] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/admin/versions')
|
||||
.then(r => r.json())
|
||||
.then(d => setVersions(d.versions || []))
|
||||
}, [])
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setSubmitting(true)
|
||||
const res = await fetch('/api/admin/versions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
description: description || undefined,
|
||||
parent_id: parentId || undefined,
|
||||
}),
|
||||
})
|
||||
setSubmitting(false)
|
||||
if (res.ok) {
|
||||
const d = await res.json()
|
||||
router.push(`/pitch-admin/versions/${d.version.id}`)
|
||||
} else {
|
||||
const d = await res.json().catch(() => ({}))
|
||||
setError(d.error || 'Creation failed')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-xl">
|
||||
<Link href="/pitch-admin/versions" className="inline-flex items-center gap-2 text-sm text-white/50 hover:text-white/80 mb-6">
|
||||
<ArrowLeft className="w-4 h-4" /> Back to versions
|
||||
</Link>
|
||||
|
||||
<h1 className="text-2xl font-semibold text-white mb-2">Create Version</h1>
|
||||
<p className="text-sm text-white/50 mb-6">
|
||||
A new draft will be created with a full copy of all pitch data.
|
||||
Choose a parent to fork from, or leave empty to snapshot the current base tables.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="bg-white/[0.04] border border-white/[0.08] rounded-2xl p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-white/60 uppercase tracking-wider mb-2">
|
||||
Name <span className="text-rose-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
required
|
||||
placeholder="e.g. Conservative Q4, Series A Ready"
|
||||
className="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2.5 text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/40"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-white/60 uppercase tracking-wider mb-2">Description</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="Optional notes about this version"
|
||||
className="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2.5 text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/40 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-white/60 uppercase tracking-wider mb-2">Fork from</label>
|
||||
<select
|
||||
value={parentId}
|
||||
onChange={e => setParentId(e.target.value)}
|
||||
className="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2.5 text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/40"
|
||||
>
|
||||
<option value="">Base tables (current pitch data)</option>
|
||||
{versions.map(v => (
|
||||
<option key={v.id} value={v.id}>{v.name} ({v.status})</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-sm text-rose-300 bg-rose-500/10 border border-rose-500/20 rounded-lg px-3 py-2">{error}</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<Link href="/pitch-admin/versions" className="text-sm text-white/60 hover:text-white px-4 py-2">Cancel</Link>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="bg-gradient-to-r from-indigo-500 to-purple-600 hover:from-indigo-600 hover:to-purple-700 text-white text-sm font-medium px-5 py-2.5 rounded-lg disabled:opacity-50 shadow-lg shadow-indigo-500/20"
|
||||
>
|
||||
{submitting ? 'Creating…' : 'Create draft'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { GitBranch, Plus, Lock, Pencil, Trash2, GitFork, Users } from 'lucide-react'
|
||||
|
||||
interface Version {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
parent_id: string | null
|
||||
status: 'draft' | 'committed'
|
||||
created_by_name: string | null
|
||||
created_by_email: string | null
|
||||
committed_at: string | null
|
||||
created_at: string
|
||||
assigned_count: number
|
||||
}
|
||||
|
||||
export default function VersionsPage() {
|
||||
const [versions, setVersions] = useState<Version[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [busy, setBusy] = useState<string | null>(null)
|
||||
const [toast, setToast] = useState<string | null>(null)
|
||||
|
||||
function flashToast(msg: string) { setToast(msg); setTimeout(() => setToast(null), 3000) }
|
||||
|
||||
async function load() {
|
||||
setLoading(true)
|
||||
const res = await fetch('/api/admin/versions')
|
||||
if (res.ok) { const d = await res.json(); setVersions(d.versions) }
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => { load() }, [])
|
||||
|
||||
async function commitVersion(id: string) {
|
||||
if (!confirm('Commit this version? It becomes immutable and available for investor assignment.')) return
|
||||
setBusy(id)
|
||||
const res = await fetch(`/api/admin/versions/${id}/commit`, { method: 'POST' })
|
||||
setBusy(null)
|
||||
if (res.ok) { flashToast('Committed'); load() }
|
||||
else { const d = await res.json().catch(() => ({})); flashToast(d.error || 'Failed') }
|
||||
}
|
||||
|
||||
async function forkVersion(id: string) {
|
||||
const name = prompt('Name for the new draft:')
|
||||
if (!name) return
|
||||
setBusy(id)
|
||||
const res = await fetch(`/api/admin/versions/${id}/fork`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name }),
|
||||
})
|
||||
setBusy(null)
|
||||
if (res.ok) { flashToast('Forked'); load() }
|
||||
else { const d = await res.json().catch(() => ({})); flashToast(d.error || 'Failed') }
|
||||
}
|
||||
|
||||
async function deleteVersion(id: string, name: string) {
|
||||
if (!confirm(`Delete "${name}"? This cannot be undone.`)) return
|
||||
setBusy(id)
|
||||
const res = await fetch(`/api/admin/versions/${id}`, { method: 'DELETE' })
|
||||
setBusy(null)
|
||||
if (res.ok) { flashToast('Deleted'); load() }
|
||||
else { const d = await res.json().catch(() => ({})); flashToast(d.error || 'Failed') }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-white">Pitch Versions</h1>
|
||||
<p className="text-sm text-white/50 mt-1">
|
||||
{versions.length} version{versions.length !== 1 ? 's' : ''} — each is a complete snapshot of all pitch data
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/pitch-admin/versions/new"
|
||||
className="bg-gradient-to-r from-indigo-500 to-purple-600 hover:from-indigo-600 hover:to-purple-700 text-white text-sm font-medium px-4 py-2 rounded-lg shadow-lg shadow-indigo-500/20 flex items-center gap-2"
|
||||
>
|
||||
<Plus className="w-4 h-4" /> New Version
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64"><div className="w-8 h-8 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin" /></div>
|
||||
) : versions.length === 0 ? (
|
||||
<div className="bg-white/[0.04] border border-white/[0.06] rounded-2xl p-12 text-center">
|
||||
<GitBranch className="w-12 h-12 text-white/20 mx-auto mb-4" />
|
||||
<p className="text-white/60 mb-4">No versions yet. Create your first version to snapshot the current pitch data.</p>
|
||||
<Link
|
||||
href="/pitch-admin/versions/new"
|
||||
className="bg-indigo-500 hover:bg-indigo-600 text-white text-sm font-medium px-4 py-2 rounded-lg inline-flex items-center gap-2"
|
||||
>
|
||||
<Plus className="w-4 h-4" /> Create First Version
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{versions.map(v => {
|
||||
const parent = v.parent_id ? versions.find(p => p.id === v.parent_id) : null
|
||||
return (
|
||||
<div key={v.id} className="bg-white/[0.04] border border-white/[0.06] rounded-2xl p-5 hover:border-white/[0.12] transition-colors">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||
<Link href={`/pitch-admin/versions/${v.id}`} className="text-base font-semibold text-white hover:text-indigo-300">
|
||||
{v.name}
|
||||
</Link>
|
||||
<span className={`text-[9px] px-2 py-0.5 rounded-full border uppercase font-semibold ${
|
||||
v.status === 'committed'
|
||||
? 'bg-green-500/15 text-green-300 border-green-500/30'
|
||||
: 'bg-amber-500/15 text-amber-300 border-amber-500/30'
|
||||
}`}>
|
||||
{v.status}
|
||||
</span>
|
||||
{v.assigned_count > 0 && (
|
||||
<span className="text-[9px] px-2 py-0.5 rounded-full bg-indigo-500/15 text-indigo-300 border border-indigo-500/30 flex items-center gap-1">
|
||||
<Users className="w-3 h-3" /> {v.assigned_count}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{v.description && <p className="text-sm text-white/50 mb-1">{v.description}</p>}
|
||||
<div className="flex items-center gap-3 text-xs text-white/40">
|
||||
<span>by {v.created_by_name || v.created_by_email || 'system'}</span>
|
||||
<span>{new Date(v.created_at).toLocaleDateString()}</span>
|
||||
{parent && (
|
||||
<span className="flex items-center gap-1">
|
||||
<GitBranch className="w-3 h-3" /> from {parent.name}
|
||||
</span>
|
||||
)}
|
||||
{v.committed_at && <span>committed {new Date(v.committed_at).toLocaleDateString()}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Link
|
||||
href={`/pitch-admin/versions/${v.id}`}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-white/50 hover:bg-white/[0.06] hover:text-white"
|
||||
title="Edit"
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</Link>
|
||||
{v.status === 'draft' && (
|
||||
<button
|
||||
onClick={() => commitVersion(v.id)}
|
||||
disabled={busy === v.id}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-white/50 hover:bg-green-500/15 hover:text-green-300 disabled:opacity-30"
|
||||
title="Commit"
|
||||
>
|
||||
<Lock className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => forkVersion(v.id)}
|
||||
disabled={busy === v.id}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-white/50 hover:bg-indigo-500/15 hover:text-indigo-300 disabled:opacity-30"
|
||||
title="Fork"
|
||||
>
|
||||
<GitFork className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteVersion(v.id, v.name)}
|
||||
disabled={busy === v.id}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-white/50 hover:bg-rose-500/15 hover:text-rose-300 disabled:opacity-30"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick diff link if has parent */}
|
||||
{v.parent_id && (
|
||||
<div className="mt-3 pt-3 border-t border-white/[0.04]">
|
||||
<Link
|
||||
href={`/pitch-admin/versions/${v.id}/diff/${v.parent_id}`}
|
||||
className="text-xs text-indigo-400 hover:text-indigo-300"
|
||||
>
|
||||
Compare with parent →
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{toast && (
|
||||
<div className="fixed bottom-6 right-6 bg-indigo-500/20 border border-indigo-500/40 text-indigo-200 text-sm px-4 py-2 rounded-lg backdrop-blur-sm z-50">
|
||||
{toast}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Language, PitchData } from '@/lib/types'
|
||||
import PitchDeck from '@/components/PitchDeck'
|
||||
|
||||
export default function PreviewPage() {
|
||||
const { versionId } = useParams<{ versionId: string }>()
|
||||
const [data, setData] = useState<PitchData | null>(null)
|
||||
const [versionMeta, setVersionMeta] = useState<{ name: string; status: string } | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [lang, setLang] = useState<Language>('de')
|
||||
|
||||
const toggleLanguage = useCallback(() => {
|
||||
setLang(prev => prev === 'de' ? 'en' : 'de')
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!versionId) return
|
||||
setLoading(true)
|
||||
fetch(`/api/preview-data/${versionId}`)
|
||||
.then(async r => {
|
||||
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || 'Failed to load')
|
||||
return r.json()
|
||||
})
|
||||
.then(d => {
|
||||
if (d._version) {
|
||||
setVersionMeta(d._version)
|
||||
delete d._version
|
||||
}
|
||||
setData(d)
|
||||
})
|
||||
.catch(e => setError(e.message))
|
||||
.finally(() => setLoading(false))
|
||||
}, [versionId])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="h-screen flex items-center justify-center bg-[#0a0a1a]">
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<p className="text-white/40 text-sm">Loading preview...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="h-screen flex items-center justify-center bg-[#0a0a1a]">
|
||||
<div className="text-center max-w-md px-6">
|
||||
<p className="text-rose-400 mb-2">Preview Error</p>
|
||||
<p className="text-white/40 text-sm">{error || 'No data found for this version'}</p>
|
||||
<p className="text-white/30 text-xs mt-4">Make sure you are logged in as an admin.</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Render PitchDeck with no investor (no watermark, no audit) — admin preview only
|
||||
// The banner at the top indicates this is a preview
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* Preview banner */}
|
||||
<div className="fixed top-0 left-0 right-0 z-[100] bg-amber-500/90 text-black text-center py-1.5 text-xs font-semibold tracking-wide">
|
||||
PREVIEW: {versionMeta?.name ?? 'Loading...'} — {versionMeta?.status === 'draft' ? 'Draft' : 'Committed'}
|
||||
</div>
|
||||
<PitchDeck
|
||||
lang={lang}
|
||||
onToggleLanguage={toggleLanguage}
|
||||
investor={null}
|
||||
onLogout={() => {}}
|
||||
previewData={data}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -154,10 +154,11 @@ export default function ChatFAB({
|
||||
ttsAbortRef.current = controller
|
||||
|
||||
try {
|
||||
const textLang = /[äöüÄÖÜß]|(?:^|\s)(?:das|die|der|und|ist|wir|ein|für|mit|auf|von|den|des)\s/i.test(cleanText) ? 'de' : lang
|
||||
const res = await fetch('/api/presenter/tts', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: cleanText, language: lang }),
|
||||
body: JSON.stringify({ text: cleanText, language: textLang }),
|
||||
signal: controller.signal,
|
||||
})
|
||||
if (!res.ok || controller.signal.aborted) return
|
||||
@@ -286,10 +287,11 @@ export default function ChatFAB({
|
||||
}
|
||||
|
||||
// If FAQ matched and has a goto_slide, add a GOTO marker to the response
|
||||
if (faqMatch?.goto_slide) {
|
||||
const gotoIdx = SLIDE_ORDER.indexOf(faqMatch.goto_slide)
|
||||
const topMatch = faqMatches[0]
|
||||
if (topMatch?.goto_slide) {
|
||||
const gotoIdx = SLIDE_ORDER.indexOf(topMatch.goto_slide)
|
||||
if (gotoIdx >= 0) {
|
||||
const suffix = `\n\n[GOTO:${faqMatch.goto_slide}]`
|
||||
const suffix = `\n\n[GOTO:${topMatch.goto_slide}]`
|
||||
content += suffix
|
||||
setMessages(prev => {
|
||||
const updated = [...prev]
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Menu, X, Maximize, Minimize, Bot } from 'lucide-react'
|
||||
import { Menu, X, Maximize, Minimize, Bot, Sun, Moon } from 'lucide-react'
|
||||
import { Language } from '@/lib/types'
|
||||
import { t } from '@/lib/i18n'
|
||||
|
||||
@@ -25,6 +25,15 @@ export default function NavigationFAB({
|
||||
}: NavigationFABProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||
const [isLightMode, setIsLightMode] = useState(false)
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
setIsLightMode(prev => {
|
||||
const next = !prev
|
||||
document.documentElement.classList.toggle('theme-light', next)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
const i = t(lang)
|
||||
|
||||
const toggleFullscreen = useCallback(() => {
|
||||
@@ -138,6 +147,23 @@ export default function NavigationFAB({
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Theme Toggle */}
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="w-full flex items-center justify-between px-3 py-2 rounded-lg
|
||||
bg-white/[0.05] hover:bg-white/[0.1] transition-colors text-sm"
|
||||
>
|
||||
<span className="text-white/50">{lang === 'de' ? 'Modus' : 'Mode'}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium flex items-center gap-1 ${!isLightMode ? 'bg-indigo-500 text-white' : 'text-white/40'}`}>
|
||||
<Moon className="w-3 h-3" /> {lang === 'de' ? 'Nacht' : 'Dark'}
|
||||
</span>
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium flex items-center gap-1 ${isLightMode ? 'bg-amber-500 text-white' : 'text-white/40'}`}>
|
||||
<Sun className="w-3 h-3" /> {lang === 'de' ? 'Tag' : 'Light'}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Fullscreen */}
|
||||
<button
|
||||
onClick={toggleFullscreen}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { AnimatePresence } from 'framer-motion'
|
||||
import { useSlideNavigation } from '@/lib/hooks/useSlideNavigation'
|
||||
import { useKeyboard } from '@/lib/hooks/useKeyboard'
|
||||
@@ -29,7 +29,6 @@ import ProductSlide from './slides/ProductSlide'
|
||||
import HowItWorksSlide from './slides/HowItWorksSlide'
|
||||
import MarketSlide from './slides/MarketSlide'
|
||||
import BusinessModelSlide from './slides/BusinessModelSlide'
|
||||
import TractionSlide from './slides/TractionSlide'
|
||||
import CompetitionSlide from './slides/CompetitionSlide'
|
||||
import TeamSlide from './slides/TeamSlide'
|
||||
import FinancialsSlide from './slides/FinancialsSlide'
|
||||
@@ -41,18 +40,51 @@ import GTMSlide from './slides/GTMSlide'
|
||||
import RegulatorySlide from './slides/RegulatorySlide'
|
||||
import EngineeringSlide from './slides/EngineeringSlide'
|
||||
import AIPipelineSlide from './slides/AIPipelineSlide'
|
||||
import USPSlide from './slides/USPSlide'
|
||||
import DisclaimerSlide from './slides/DisclaimerSlide'
|
||||
import ExecutiveSummarySlide from './slides/ExecutiveSummarySlide'
|
||||
import RegulatoryLandscapeSlide from './slides/RegulatoryLandscapeSlide'
|
||||
import CapTableSlide from './slides/CapTableSlide'
|
||||
import SavingsSlide from './slides/SavingsSlide'
|
||||
import SDKDemoSlide from './slides/SDKDemoSlide'
|
||||
import StrategySlide from './slides/StrategySlide'
|
||||
import FinanzplanSlide from './slides/FinanzplanSlide'
|
||||
import GlossarySlide from './slides/GlossarySlide'
|
||||
import RiskSlide from './slides/RiskSlide'
|
||||
import MilestonesSlide from './slides/MilestonesSlide'
|
||||
|
||||
interface PitchDeckProps {
|
||||
lang: Language
|
||||
onToggleLanguage: () => void
|
||||
investor: Investor | null
|
||||
onLogout: () => void
|
||||
previewData?: PitchData | null
|
||||
}
|
||||
|
||||
export default function PitchDeck({ lang, onToggleLanguage, investor, onLogout }: PitchDeckProps) {
|
||||
const { data, loading, error } = usePitchData()
|
||||
export default function PitchDeck({ lang, onToggleLanguage, investor, onLogout, previewData }: PitchDeckProps) {
|
||||
const fetched = usePitchData()
|
||||
const data = previewData || fetched.data
|
||||
const loading = previewData ? false : fetched.loading
|
||||
const error = previewData ? null : fetched.error
|
||||
const nav = useSlideNavigation()
|
||||
const [fabOpen, setFabOpen] = useState(false)
|
||||
const isWandeldarlehen = (data?.funding?.instrument || '').toLowerCase() === 'wandeldarlehen'
|
||||
|
||||
// For version previews: use the version's default FM scenario instead of base table default
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const fmScenarios = (previewData as any)?.fm_scenarios as Array<{ id: string; is_default?: boolean }> | undefined
|
||||
const preferredScenarioId = fmScenarios?.[0]?.is_default
|
||||
? fmScenarios[0].id
|
||||
: fmScenarios?.length === 1
|
||||
? fmScenarios[0].id
|
||||
: null
|
||||
|
||||
// Skip cap-table slide for Wandeldarlehen versions
|
||||
useEffect(() => {
|
||||
if (nav.currentSlide === 'cap-table' && isWandeldarlehen) {
|
||||
nav.nextSlide()
|
||||
}
|
||||
}, [nav.currentSlide, isWandeldarlehen, nav.nextSlide])
|
||||
|
||||
const presenter = usePresenterMode({
|
||||
goToSlide: nav.goToSlide,
|
||||
@@ -128,12 +160,18 @@ export default function PitchDeck({ lang, onToggleLanguage, investor, onLogout }
|
||||
isPresenting={presenter.state !== 'idle'}
|
||||
/>
|
||||
)
|
||||
case 'executive-summary':
|
||||
return <ExecutiveSummarySlide lang={lang} data={data} investorId={investor?.id || null} preferredScenarioId={preferredScenarioId} isWandeldarlehen={isWandeldarlehen} />
|
||||
case 'cover':
|
||||
return <CoverSlide lang={lang} onNext={nav.nextSlide} funding={data.funding} />
|
||||
case 'problem':
|
||||
return <ProblemSlide lang={lang} />
|
||||
case 'solution':
|
||||
return <SolutionSlide lang={lang} />
|
||||
case 'usp':
|
||||
return <USPSlide lang={lang} />
|
||||
case 'regulatory-landscape':
|
||||
return <RegulatoryLandscapeSlide lang={lang} />
|
||||
case 'product':
|
||||
return <ProductSlide lang={lang} products={data.products} />
|
||||
case 'how-it-works':
|
||||
@@ -141,31 +179,48 @@ export default function PitchDeck({ lang, onToggleLanguage, investor, onLogout }
|
||||
case 'market':
|
||||
return <MarketSlide lang={lang} market={data.market} />
|
||||
case 'business-model':
|
||||
return <BusinessModelSlide lang={lang} products={data.products} />
|
||||
return <BusinessModelSlide lang={lang} products={data.products} investorId={investor?.id || null} preferredScenarioId={preferredScenarioId} isWandeldarlehen={isWandeldarlehen} />
|
||||
case 'traction':
|
||||
return <TractionSlide lang={lang} milestones={data.milestones} metrics={data.metrics} />
|
||||
return <MilestonesSlide lang={lang} />
|
||||
case 'competition':
|
||||
return <CompetitionSlide lang={lang} features={data.features} competitors={data.competitors} />
|
||||
case 'team':
|
||||
return <TeamSlide lang={lang} team={data.team} />
|
||||
case 'financials':
|
||||
return <FinancialsSlide lang={lang} investorId={investor?.id || null} />
|
||||
return <FinancialsSlide lang={lang} investorId={investor?.id || null} preferredScenarioId={preferredScenarioId} isWandeldarlehen={isWandeldarlehen} />
|
||||
case 'the-ask':
|
||||
return <TheAskSlide lang={lang} funding={data.funding} />
|
||||
return <TheAskSlide lang={lang} funding={data.funding} isWandeldarlehen={isWandeldarlehen} />
|
||||
case 'cap-table':
|
||||
if (isWandeldarlehen) return null
|
||||
return <CapTableSlide lang={lang} />
|
||||
case 'customer-savings':
|
||||
return <SavingsSlide lang={lang} />
|
||||
case 'ai-qa':
|
||||
return <AIQASlide lang={lang} />
|
||||
case 'annex-assumptions':
|
||||
return <AssumptionsSlide lang={lang} />
|
||||
return <AssumptionsSlide lang={lang} investorId={investor?.id || null} preferredScenarioId={preferredScenarioId} isWandeldarlehen={isWandeldarlehen} />
|
||||
case 'annex-architecture':
|
||||
return <ArchitectureSlide lang={lang} />
|
||||
case 'annex-gtm':
|
||||
return <GTMSlide lang={lang} />
|
||||
return <GTMSlide lang={lang} isWandeldarlehen={isWandeldarlehen} />
|
||||
case 'annex-regulatory':
|
||||
return <RegulatorySlide lang={lang} />
|
||||
case 'annex-engineering':
|
||||
return <EngineeringSlide lang={lang} />
|
||||
case 'annex-aipipeline':
|
||||
return <AIPipelineSlide lang={lang} />
|
||||
case 'annex-sdk-demo':
|
||||
return <SDKDemoSlide lang={lang} />
|
||||
case 'annex-strategy':
|
||||
return <StrategySlide lang={lang} isWandeldarlehen={isWandeldarlehen} />
|
||||
case 'annex-finanzplan':
|
||||
return <FinanzplanSlide lang={lang} investorId={investor?.id || null} preferredScenarioId={preferredScenarioId} isWandeldarlehen={isWandeldarlehen} />
|
||||
case 'annex-glossary':
|
||||
return <GlossarySlide lang={lang} />
|
||||
case 'risks':
|
||||
return <RiskSlide lang={lang} />
|
||||
case 'legal-disclaimer':
|
||||
return <DisclaimerSlide lang={lang} />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
FileText,
|
||||
TrendingUp,
|
||||
ShieldCheck,
|
||||
GitBranch,
|
||||
LogOut,
|
||||
Menu,
|
||||
X,
|
||||
@@ -22,6 +23,7 @@ interface AdminShellProps {
|
||||
const NAV = [
|
||||
{ href: '/pitch-admin', label: 'Dashboard', icon: LayoutDashboard, exact: true },
|
||||
{ href: '/pitch-admin/investors', label: 'Investors', icon: Users },
|
||||
{ href: '/pitch-admin/versions', label: 'Versions', icon: GitBranch },
|
||||
{ href: '/pitch-admin/audit', label: 'Audit Log', icon: FileText },
|
||||
{ href: '/pitch-admin/financial-model', label: 'Financial Model', icon: TrendingUp },
|
||||
{ href: '/pitch-admin/admins', label: 'Admins', icon: ShieldCheck },
|
||||
@@ -43,7 +45,7 @@ export default function AdminShell({ admin, children }: AdminShellProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0a0a1a] text-white flex">
|
||||
<div className="h-screen bg-[#0a0a1a] text-white flex overflow-hidden">
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
className={`fixed lg:static inset-y-0 left-0 z-40 w-64 bg-black/40 backdrop-blur-xl border-r border-white/[0.06]
|
||||
@@ -90,6 +92,11 @@ export default function AdminShell({ admin, children }: AdminShellProps) {
|
||||
<div className="px-3 py-2 mb-2">
|
||||
<div className="text-sm font-medium text-white/90 truncate">{admin.name}</div>
|
||||
<div className="text-xs text-white/40 truncate">{admin.email}</div>
|
||||
<div className="mt-1.5 flex items-center gap-1.5">
|
||||
<span className="text-[9px] font-mono bg-white/[0.06] text-white/30 px-1.5 py-0.5 rounded">
|
||||
{process.env.NEXT_PUBLIC_GIT_SHA ?? 'dev'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={logout}
|
||||
@@ -111,7 +118,7 @@ export default function AdminShell({ admin, children }: AdminShellProps) {
|
||||
)}
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<div className="flex-1 flex flex-col min-w-0 min-h-0">
|
||||
<header className="lg:hidden flex items-center justify-between px-4 py-3 border-b border-white/[0.06]">
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { X, Plus } from 'lucide-react'
|
||||
|
||||
interface ArrayFieldProps {
|
||||
label: string
|
||||
values: string[]
|
||||
onChange: (v: string[]) => void
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
export default function ArrayField({ label, values, onChange, placeholder }: ArrayFieldProps) {
|
||||
const [input, setInput] = useState('')
|
||||
|
||||
function add() {
|
||||
const v = input.trim()
|
||||
if (v && !values.includes(v)) {
|
||||
onChange([...values, v])
|
||||
setInput('')
|
||||
}
|
||||
}
|
||||
|
||||
function remove(idx: number) {
|
||||
onChange(values.filter((_, i) => i !== idx))
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-white/60 uppercase tracking-wider mb-2">{label}</label>
|
||||
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||
{values.map((v, i) => (
|
||||
<span key={i} className="inline-flex items-center gap-1 bg-indigo-500/15 text-indigo-300 text-xs px-2 py-1 rounded-lg border border-indigo-500/20">
|
||||
{v}
|
||||
<button onClick={() => remove(i)} className="hover:text-rose-300">
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); add() } }}
|
||||
placeholder={placeholder || 'Type and press Enter'}
|
||||
className="flex-1 bg-black/30 border border-white/10 rounded-lg px-3 py-1.5 text-sm text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/40 placeholder:text-white/20"
|
||||
/>
|
||||
<button onClick={add} className="bg-white/[0.06] hover:bg-white/[0.1] text-white/60 p-1.5 rounded-lg">
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
'use client'
|
||||
|
||||
interface BilingualFieldProps {
|
||||
label: string
|
||||
valueDe: string
|
||||
valueEn: string
|
||||
onChangeDe: (v: string) => void
|
||||
onChangeEn: (v: string) => void
|
||||
multiline?: boolean
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
export default function BilingualField({
|
||||
label, valueDe, valueEn, onChangeDe, onChangeEn, multiline, placeholder,
|
||||
}: BilingualFieldProps) {
|
||||
const inputClass = 'w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/40 placeholder:text-white/20'
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-white/60 uppercase tracking-wider mb-2">{label}</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<span className="text-[10px] text-white/40 font-semibold">DE</span>
|
||||
</div>
|
||||
{multiline ? (
|
||||
<textarea
|
||||
value={valueDe || ''}
|
||||
onChange={e => onChangeDe(e.target.value)}
|
||||
rows={3}
|
||||
placeholder={placeholder}
|
||||
className={`${inputClass} resize-none`}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
value={valueDe || ''}
|
||||
onChange={e => onChangeDe(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className={inputClass}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<span className="text-[10px] text-white/40 font-semibold">EN</span>
|
||||
</div>
|
||||
{multiline ? (
|
||||
<textarea
|
||||
value={valueEn || ''}
|
||||
onChange={e => onChangeEn(e.target.value)}
|
||||
rows={3}
|
||||
placeholder={placeholder}
|
||||
className={`${inputClass} resize-none`}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
value={valueEn || ''}
|
||||
onChange={e => onChangeEn(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className={inputClass}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { ChevronDown, ChevronRight, Plus, Trash2, GripVertical } from 'lucide-react'
|
||||
|
||||
interface CardListProps {
|
||||
items: Record<string, unknown>[]
|
||||
onChange: (items: Record<string, unknown>[]) => void
|
||||
titleKey: string
|
||||
subtitleKey?: string
|
||||
renderCard: (item: Record<string, unknown>, update: (key: string, value: unknown) => void) => React.ReactNode
|
||||
newItemTemplate?: Record<string, unknown>
|
||||
addLabel?: string
|
||||
}
|
||||
|
||||
export default function CardList({
|
||||
items, onChange, titleKey, subtitleKey, renderCard, newItemTemplate, addLabel,
|
||||
}: CardListProps) {
|
||||
const [expandedIdx, setExpandedIdx] = useState<number | null>(null)
|
||||
|
||||
function updateItem(idx: number, key: string, value: unknown) {
|
||||
onChange(items.map((item, i) => i === idx ? { ...item, [key]: value } : item))
|
||||
}
|
||||
|
||||
function addItem() {
|
||||
const newItem = newItemTemplate || (() => {
|
||||
const template: Record<string, unknown> = {}
|
||||
if (items.length > 0) {
|
||||
Object.keys(items[0]).forEach(k => {
|
||||
const sample = items[0][k]
|
||||
template[k] = Array.isArray(sample) ? [] : typeof sample === 'number' ? 0 : typeof sample === 'boolean' ? false : ''
|
||||
})
|
||||
}
|
||||
if ('sort_order' in template) template.sort_order = items.length
|
||||
return template
|
||||
})()
|
||||
onChange([...items, newItem])
|
||||
setExpandedIdx(items.length)
|
||||
}
|
||||
|
||||
function removeItem(idx: number) {
|
||||
if (!confirm('Remove this item?')) return
|
||||
onChange(items.filter((_, i) => i !== idx))
|
||||
if (expandedIdx === idx) setExpandedIdx(null)
|
||||
}
|
||||
|
||||
function moveUp(idx: number) {
|
||||
if (idx === 0) return
|
||||
const copy = [...items]
|
||||
;[copy[idx - 1], copy[idx]] = [copy[idx], copy[idx - 1]]
|
||||
onChange(copy)
|
||||
setExpandedIdx(idx - 1)
|
||||
}
|
||||
|
||||
function moveDown(idx: number) {
|
||||
if (idx >= items.length - 1) return
|
||||
const copy = [...items]
|
||||
;[copy[idx], copy[idx + 1]] = [copy[idx + 1], copy[idx]]
|
||||
onChange(copy)
|
||||
setExpandedIdx(idx + 1)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{items.map((item, idx) => {
|
||||
const isExpanded = expandedIdx === idx
|
||||
const title = String(item[titleKey] || `Item ${idx + 1}`)
|
||||
const subtitle = subtitleKey ? String(item[subtitleKey] || '') : ''
|
||||
|
||||
return (
|
||||
<div key={idx} className="border border-white/[0.06] rounded-xl overflow-hidden">
|
||||
<button
|
||||
onClick={() => setExpandedIdx(isExpanded ? null : idx)}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-white/[0.02] text-left"
|
||||
>
|
||||
<div className="flex items-center gap-1 text-white/30">
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); moveUp(idx) }}
|
||||
className="hover:text-white/60 p-0.5"
|
||||
title="Move up"
|
||||
>
|
||||
<GripVertical className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
{isExpanded ? <ChevronDown className="w-4 h-4 text-white/40" /> : <ChevronRight className="w-4 h-4 text-white/40" />}
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm text-white/90 font-medium truncate block">{title}</span>
|
||||
{subtitle && <span className="text-xs text-white/40 truncate block">{subtitle}</span>}
|
||||
</div>
|
||||
<span className="text-[9px] text-white/30 font-mono">#{idx + 1}</span>
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); removeItem(idx) }}
|
||||
className="text-white/30 hover:text-rose-400 p-1"
|
||||
title="Remove"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div className="px-4 pb-4 pt-1 border-t border-white/[0.04] space-y-4">
|
||||
{renderCard(item, (key, value) => updateItem(idx, key, value))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<button
|
||||
onClick={addItem}
|
||||
className="w-full flex items-center justify-center gap-2 py-2.5 text-xs text-white/50 hover:text-white border border-dashed border-white/[0.1] hover:border-white/[0.2] rounded-xl transition-colors"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" /> {addLabel || 'Add item'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
'use client'
|
||||
|
||||
interface FormFieldProps {
|
||||
label: string
|
||||
value: string | number | boolean
|
||||
onChange: (v: string | number | boolean) => void
|
||||
type?: 'text' | 'number' | 'date' | 'url' | 'checkbox' | 'select' | 'color'
|
||||
placeholder?: string
|
||||
options?: { value: string; label: string }[]
|
||||
hint?: string
|
||||
}
|
||||
|
||||
export default function FormField({
|
||||
label, value, onChange, type = 'text', placeholder, options, hint,
|
||||
}: FormFieldProps) {
|
||||
const inputClass = 'w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/40 placeholder:text-white/20'
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-white/60 uppercase tracking-wider mb-2">{label}</label>
|
||||
|
||||
{type === 'checkbox' ? (
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!value}
|
||||
onChange={e => onChange(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-white/20 bg-black/30 text-indigo-500 focus:ring-indigo-500/40"
|
||||
/>
|
||||
<span className="text-sm text-white/70">{placeholder || label}</span>
|
||||
</label>
|
||||
) : type === 'select' && options ? (
|
||||
<select
|
||||
value={String(value)}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
className={inputClass}
|
||||
>
|
||||
{options.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
) : type === 'color' ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={String(value) || '#6366f1'}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
className="w-10 h-10 rounded-lg border border-white/10 cursor-pointer bg-transparent"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={String(value)}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
className={`${inputClass} flex-1`}
|
||||
placeholder="#6366f1"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<input
|
||||
type={type}
|
||||
value={value as string | number}
|
||||
onChange={e => onChange(type === 'number' ? Number(e.target.value) || 0 : e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className={inputClass}
|
||||
/>
|
||||
)}
|
||||
|
||||
{hint && <p className="text-[10px] text-white/30 mt-1">{hint}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
'use client'
|
||||
|
||||
import { Plus, Trash2 } from 'lucide-react'
|
||||
|
||||
interface RowTableProps {
|
||||
rows: Record<string, unknown>[]
|
||||
onChange: (rows: Record<string, unknown>[]) => void
|
||||
columns?: { key: string; label: string; type?: 'text' | 'number' }[]
|
||||
addLabel?: string
|
||||
}
|
||||
|
||||
export default function RowTable({ rows, onChange, columns, addLabel }: RowTableProps) {
|
||||
// Auto-detect columns from first row if not provided
|
||||
const cols = columns || (rows.length > 0
|
||||
? Object.keys(rows[0]).filter(k => k !== 'id' && k !== 'sort_order').map(k => ({
|
||||
key: k,
|
||||
label: k.replace(/_/g, ' '),
|
||||
type: (typeof rows[0][k] === 'number' ? 'number' : 'text') as 'text' | 'number',
|
||||
}))
|
||||
: [])
|
||||
|
||||
function updateCell(rowIdx: number, key: string, value: string) {
|
||||
const col = cols.find(c => c.key === key)
|
||||
const parsedValue = col?.type === 'number' ? (Number(value) || 0) : value
|
||||
onChange(rows.map((r, i) => i === rowIdx ? { ...r, [key]: parsedValue } : r))
|
||||
}
|
||||
|
||||
function addRow() {
|
||||
const newRow: Record<string, unknown> = {}
|
||||
cols.forEach(c => { newRow[c.key] = c.type === 'number' ? 0 : '' })
|
||||
// Carry over id-like fields
|
||||
if (rows.length > 0 && 'id' in rows[0]) {
|
||||
newRow.id = (rows.length + 1)
|
||||
}
|
||||
if (rows.length > 0 && 'sort_order' in rows[0]) {
|
||||
newRow.sort_order = rows.length
|
||||
}
|
||||
onChange([...rows, newRow])
|
||||
}
|
||||
|
||||
function removeRow(idx: number) {
|
||||
onChange(rows.filter((_, i) => i !== idx))
|
||||
}
|
||||
|
||||
if (cols.length === 0) return <div className="text-white/40 text-sm">No columns detected</div>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-white/[0.08]">
|
||||
{cols.map(c => (
|
||||
<th key={c.key} className="text-left py-2 px-2 text-[10px] text-white/40 font-medium uppercase tracking-wider whitespace-nowrap">
|
||||
{c.label}
|
||||
</th>
|
||||
))}
|
||||
<th className="w-8" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, ri) => (
|
||||
<tr key={ri} className="border-b border-white/[0.04] hover:bg-white/[0.02]">
|
||||
{cols.map(c => (
|
||||
<td key={c.key} className="py-1 px-2">
|
||||
<input
|
||||
type={c.type || 'text'}
|
||||
value={(row[c.key] as string | number) ?? ''}
|
||||
onChange={e => updateCell(ri, c.key, e.target.value)}
|
||||
className="w-full bg-transparent border-b border-transparent hover:border-white/10 focus:border-indigo-500/50 text-white font-mono text-xs py-1 focus:outline-none min-w-[60px]"
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
<td className="py-1 px-1">
|
||||
<button onClick={() => removeRow(ri)} className="text-white/30 hover:text-rose-400 p-1" title="Remove">
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<button
|
||||
onClick={addRow}
|
||||
className="mt-2 text-xs text-white/50 hover:text-white flex items-center gap-1 px-2 py-1 rounded hover:bg-white/[0.04]"
|
||||
>
|
||||
<Plus className="w-3 h-3" /> {addLabel || 'Add row'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Language } from '@/lib/types'
|
||||
import { t } from '@/lib/i18n'
|
||||
import GradientText from '../ui/GradientText'
|
||||
@@ -18,11 +18,14 @@ import {
|
||||
Activity,
|
||||
Shield,
|
||||
Cpu,
|
||||
MessageSquare,
|
||||
Eye,
|
||||
Gauge,
|
||||
Network,
|
||||
Sparkles,
|
||||
Scale,
|
||||
BookOpen,
|
||||
Gavel,
|
||||
Globe,
|
||||
} from 'lucide-react'
|
||||
|
||||
interface AIPipelineSlideProps {
|
||||
@@ -31,16 +34,29 @@ interface AIPipelineSlideProps {
|
||||
|
||||
type PipelineTab = 'rag' | 'agents' | 'quality'
|
||||
|
||||
type PipelineStat = { value: number; label_de: string; label_en: string }
|
||||
|
||||
export default function AIPipelineSlide({ lang }: AIPipelineSlideProps) {
|
||||
const i = t(lang)
|
||||
const de = lang === 'de'
|
||||
const [activeTab, setActiveTab] = useState<PipelineTab>('rag')
|
||||
const [stats, setStats] = useState<Record<string, PipelineStat>>({})
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/pipeline-stats', { cache: 'no-store' })
|
||||
.then(r => r.json())
|
||||
.then(setStats)
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
const s = (key: string) => stats[key]?.value || 0
|
||||
const fmtK = (v: number) => v >= 1000 ? `${Math.round(v / 1000)}k+` : `${v}+`
|
||||
|
||||
const heroStats = [
|
||||
{ value: '38+', label: de ? 'Indexierte Verordnungen' : 'Indexed Regulations', sub: 'DSGVO · AI Act · NIS2 · CRA · BDSG · DSA · ...', color: 'text-indigo-400' },
|
||||
{ value: '6.259', label: de ? 'Extrahierte Controls' : 'Extracted Controls', sub: de ? '79% Source-Match · 9 Verordnungen' : '79% source match · 9 regulations', color: 'text-purple-400' },
|
||||
{ value: '6', label: de ? 'Qdrant Collections' : 'Qdrant Collections', sub: de ? 'Legal Corpus · DSFA · Recht · Templates · ...' : 'Legal Corpus · DSFA · Law · Templates · ...', color: 'text-emerald-400' },
|
||||
{ value: '325+', label: de ? 'Abgeleitete Pflichten' : 'Derived Obligations', sub: de ? 'NIS2 · DSGVO · AI Act · CRA · ...' : 'NIS2 · GDPR · AI Act · CRA · ...', color: 'text-amber-400' },
|
||||
{ value: fmtK(s('legal_sources')), label: de ? 'Rechtsquellen' : 'Legal Sources', sub: de ? 'EU-Verordnungen · DACH-Gesetze · Frameworks' : 'EU regulations · DACH laws · Frameworks', color: 'text-indigo-400' },
|
||||
{ value: fmtK(s('unique_controls')), label: de ? 'Unique Controls' : 'Unique Controls', sub: de ? 'Prüfbare Compliance-Anforderungen' : 'Auditable compliance requirements', color: 'text-purple-400' },
|
||||
{ value: fmtK(s('extracted_obligations')), label: de ? 'Extrahierte Pflichten' : 'Extracted Obligations', sub: de ? 'Aus Gesetzestexten abgeleitet' : 'Derived from legal texts', color: 'text-emerald-400' },
|
||||
{ value: String(s('pipeline_versions') || 6), label: de ? 'Pipeline-Versionen' : 'Pipeline Versions', sub: de ? 'Kontinuierliche Verbesserung' : 'Continuous improvement', color: 'text-amber-400' },
|
||||
]
|
||||
|
||||
const tabs: { id: PipelineTab; label: string; icon: typeof Brain }[] = [
|
||||
@@ -49,59 +65,105 @@ export default function AIPipelineSlide({ lang }: AIPipelineSlideProps) {
|
||||
{ id: 'quality', label: de ? 'QA & Infrastruktur' : 'QA & Infrastructure', icon: Gauge },
|
||||
]
|
||||
|
||||
// RAG Pipeline content
|
||||
// Source categories for investors
|
||||
const sourceCategories = [
|
||||
{
|
||||
icon: Globe,
|
||||
color: 'text-blue-400',
|
||||
bg: 'bg-blue-500/10 border-blue-500/20',
|
||||
title: de ? `EU-Verordnungen (~${s('eu_regulations') || 45})` : `EU Regulations (~${s('eu_regulations') || 45})`,
|
||||
why: de
|
||||
? 'Bindende Vorgaben für alle EU-Unternehmen — Verstöße führen zu Bußgeldern bis 4% des Jahresumsatzes.'
|
||||
: 'Binding requirements for all EU companies — violations lead to fines up to 4% of annual revenue.',
|
||||
examples: 'DSGVO · AI Act · NIS2 · CRA · MiCA · DSA · Maschinenverordnung · Batterieverordnung',
|
||||
},
|
||||
{
|
||||
icon: Scale,
|
||||
color: 'text-purple-400',
|
||||
bg: 'bg-purple-500/10 border-purple-500/20',
|
||||
title: de ? `DACH-Gesetze (~${s('dach_laws') || 85})` : `DACH Laws (~${s('dach_laws') || 85})`,
|
||||
why: de
|
||||
? 'Nationale Umsetzungen und eigenständige Gesetze — oft strenger als EU-Mindeststandards.'
|
||||
: 'National implementations and standalone laws — often stricter than EU minimum standards.',
|
||||
examples: 'BDSG · TKG · GwG · HGB · BGB · UrhG · GewO · KRITIS-DachG · AT ABGB · AT KSchG',
|
||||
},
|
||||
{
|
||||
icon: BookOpen,
|
||||
color: 'text-emerald-400',
|
||||
bg: 'bg-emerald-500/10 border-emerald-500/20',
|
||||
title: de ? 'Frameworks & Standards (~15)' : 'Frameworks & Standards (~15)',
|
||||
why: de
|
||||
? 'Branchenstandards definieren den Stand der Technik — Aufsichtsbehoerden erwarten deren Einhaltung.'
|
||||
: 'Industry standards define state of the art — regulators expect compliance with them.',
|
||||
examples: 'NIST 800-53 · OWASP ASVS · OWASP SAMM · ENISA ICS · NIST Zero Trust · CISA Secure by Design',
|
||||
},
|
||||
{
|
||||
icon: Gavel,
|
||||
color: 'text-amber-400',
|
||||
bg: 'bg-amber-500/10 border-amber-500/20',
|
||||
title: de ? 'DSFA-Leitlinien & Urteile' : 'DPIA Guidelines & Rulings',
|
||||
why: de
|
||||
? 'Urteile zeigen wie Gerichte Gesetze auslegen — entscheidend für präzise Compliance-Beratung statt generischer Antworten.'
|
||||
: 'Court rulings show how laws are interpreted — critical for precise compliance advice instead of generic answers.',
|
||||
examples: de
|
||||
? '16 Bundesländer DSFA-Leitlinien · BAG-Urteile · Datenschutzkonferenz-Beschlüsse'
|
||||
: '16 federal state DPIA guidelines · Labor court rulings · Data protection conference decisions',
|
||||
},
|
||||
]
|
||||
|
||||
// RAG Pipeline steps
|
||||
const ragPipelineSteps = [
|
||||
{
|
||||
icon: FileText,
|
||||
color: 'text-blue-400',
|
||||
bg: 'bg-blue-500/10 border-blue-500/20',
|
||||
title: de ? '1. Ingestion & QA' : '1. Ingestion & QA',
|
||||
title: de ? '1. Dokument-Ingestion' : '1. Document Ingestion',
|
||||
items: de
|
||||
? ['110+ Verordnungen und Gesetze (EU + DACH)', 'Strukturelles Chunking an Artikel/Absatz-Grenzen', '25.000+ extrahierte Prüfaspekte', 'Deduplizierung + Cross-Regulation Harmonisierung']
|
||||
: ['110+ laws and regulations (EU + DACH)', 'Structural chunking at article/paragraph boundaries', '25,000+ extracted audit aspects', 'Deduplication + cross-regulation harmonization'],
|
||||
? [`${s('legal_sources') || 380}+ Rechtsquellen aus EU, Deutschland und Österreich`, 'Strukturelles Chunking an Artikel- und Absatz-Grenzen', 'Automatische Lizenz-Klassifikation (frei / Zitat / geschützt)', 'Geschützte Normen (ISO, BSI) werden vollständig reformuliert']
|
||||
: [`${s('legal_sources') || 380}+ legal sources from EU, Germany and Austria`, 'Structural chunking at article and paragraph boundaries', 'Automatic license classification (free / citation / restricted)', 'Protected standards (ISO, BSI) are fully reformulated'],
|
||||
},
|
||||
{
|
||||
icon: Cpu,
|
||||
color: 'text-purple-400',
|
||||
bg: 'bg-purple-500/10 border-purple-500/20',
|
||||
title: de ? '2. Embedding & LLM' : '2. Embedding & LLM',
|
||||
title: de ? '2. Control-Extraktion' : '2. Control Extraction',
|
||||
items: de
|
||||
? ['BGE-M3 Multilingual (1024-dim, lokal)', '120B LLM auf OVH (via LiteLLM, OpenAI-kompatibel)', '1000B LLM auf SysEleven (BSI-zertifiziert)', 'CrossEncoder Re-Ranking + HyDE']
|
||||
: ['BGE-M3 multilingual (1024-dim, local)', '120B LLM on OVH (via LiteLLM, OpenAI-compatible)', '1000B LLM on SysEleven (BSI-certified)', 'CrossEncoder re-ranking + HyDE'],
|
||||
? ['LLM extrahiert Pflichten und Anforderungen aus jedem Textabschnitt', `${s('pipeline_versions') || 6} Pipeline-Versionen mit kontinuierlicher Qualitätsverbesserung`, `Obligation Extraction: ${fmtK(s('extracted_obligations'))} einzelne Pflichten identifiziert`, 'Atomic Control Composition: Pflichten werden zu prüfbaren Controls']
|
||||
: ['LLM extracts obligations and requirements from each text section', `${s('pipeline_versions') || 6} pipeline versions with continuous quality improvement`, `Obligation extraction: ${fmtK(s('extracted_obligations'))} individual duties identified`, 'Atomic control composition: duties become auditable controls'],
|
||||
},
|
||||
{
|
||||
icon: Database,
|
||||
color: 'text-emerald-400',
|
||||
bg: 'bg-emerald-500/10 border-emerald-500/20',
|
||||
title: de ? '3. Vektorspeicher' : '3. Vector Store',
|
||||
title: de ? '3. Deduplizierung & Speicherung' : '3. Deduplication & Storage',
|
||||
items: de
|
||||
? ['Qdrant Vector DB (Hetzner, API-Key gesichert)', '6 Collections: CE, Recht, Gesetze, Datenschutz, DSFA, Templates', 'MinIO Object Storage (Hetzner, S3-kompatibel, TLS)', '25.000+ Prüfaspekte · 110 Gesetze & Regularien · abgeleitete Pflichten']
|
||||
: ['Qdrant Vector DB (Hetzner, API-key secured)', '6 Collections: CE, Law, Statutes, Privacy, DSFA, Templates', 'MinIO object storage (Hetzner, S3-compatible, TLS)', '25,000+ audit aspects · 110 laws & regulations · derived obligations'],
|
||||
? [`${fmtK(s('generated_controls'))} generierte Controls → ${fmtK(s('unique_controls'))} nach Deduplizierung`, 'Embedding-basierte Ähnlichkeitserkennung (Cosine Similarity)', 'Cross-Regulation Harmonisierung: gleiche Pflicht aus verschiedenen Gesetzen wird zusammengeführt', `Aktuell: ${fmtK(s('unique_controls'))} atomare Master Controls`]
|
||||
: [`${fmtK(s('generated_controls'))} generated controls → ${fmtK(s('unique_controls'))} after deduplication`, 'Embedding-based similarity detection (cosine similarity)', 'Cross-regulation harmonization: same obligation from different laws is merged', `Current: ${fmtK(s('unique_controls'))} atomic master controls`],
|
||||
},
|
||||
{
|
||||
icon: Search,
|
||||
color: 'text-indigo-400',
|
||||
bg: 'bg-indigo-500/10 border-indigo-500/20',
|
||||
title: de ? '4. Hybrid Search' : '4. Hybrid Search',
|
||||
title: de ? '4. Hybrid Search & Beratung' : '4. Hybrid Search & Advisory',
|
||||
items: de
|
||||
? ['Multi-Collection-Suche mit Whitelist-Validierung', 'Deutsche Komposita-Zerlegung', 'Cross-Encoder Re-Ranking der Top-K Ergebnisse', 'Quellen-Attribution mit Artikel/Absatz-Referenz']
|
||||
: ['Multi-collection search with whitelist validation', 'German compound word decomposition', 'Cross-encoder re-ranking of top-K results', 'Source attribution with article/paragraph reference'],
|
||||
? ['Vektorsuche + Keyword-Suche über alle Rechtsquellen gleichzeitig', 'Cross-Encoder Re-Ranking für präzise Relevanz-Sortierung', 'Quellen-Attribution: Jede Antwort verweist auf Artikel und Absatz', 'Der Compliance-Agent antwortet mit Rechtsgrundlage — nicht mit Vermutungen']
|
||||
: ['Vector search + keyword search across all legal sources simultaneously', 'Cross-encoder re-ranking for precise relevance sorting', 'Source attribution: Every answer references article and paragraph', 'The compliance agent answers with legal basis — not guesswork'],
|
||||
},
|
||||
]
|
||||
|
||||
// Multi-Agent System content — UCCA + Policy Engine
|
||||
const agents = [
|
||||
{ name: 'UCCA', soul: de ? 'Use-Case Compliance' : 'Use-Case Compliance', desc: de ? 'Policy Engine (45 Regeln) + Eskalation E0–E3' : 'Policy engine (45 rules) + escalation E0–E3', color: 'text-indigo-400' },
|
||||
{ name: de ? 'Pflichten-Engine' : 'Obligations Engine', soul: de ? 'abgeleitete Pflichten' : 'derived obligations', desc: de ? 'Multi-Regulation: NIS2, DSGVO, AI Act, CRA, ...' : 'Multi-regulation: NIS2, GDPR, AI Act, CRA, ...', color: 'text-emerald-400' },
|
||||
{ name: de ? 'Compliance-Berater' : 'Compliance Advisor', soul: de ? 'Legal RAG + LLM' : 'Legal RAG + LLM', desc: de ? 'Wizard-basierter Chatbot mit Qdrant-Kontext' : 'Wizard-based chatbot with Qdrant context', color: 'text-purple-400' },
|
||||
{ name: de ? 'Dokument-Generator' : 'Document Generator', soul: de ? '20 Templates' : '20 templates', desc: de ? 'AGB, DSE, AV-Vertrag, Widerruf + 16 weitere' : 'T&C, Privacy Policy, DPA, Withdrawal + 16 more', color: 'text-amber-400' },
|
||||
{ name: de ? 'DSFA-Agent' : 'DSFA Agent', soul: de ? 'Art. 35 DSGVO' : 'Art. 35 GDPR', desc: de ? 'Risikobewertung mit Legal Context Injection' : 'Risk assessment with legal context injection', color: 'text-red-400' },
|
||||
{ name: de ? 'Schulungs-Engine' : 'Training Engine', soul: de ? 'Academy + TTS' : 'Academy + TTS', desc: de ? '28 Module · Piper TTS · Automatische Videos' : '28 modules · Piper TTS · Automatic videos', color: 'text-blue-400' },
|
||||
{ name: de ? 'Pflichten-Engine' : 'Obligations Engine', soul: `${fmtK(s('extracted_obligations'))} ${de ? 'Pflichten' : 'obligations'}`, desc: de ? 'Multi-Regulation: NIS2, DSGVO, AI Act, CRA, ...' : 'Multi-regulation: NIS2, GDPR, AI Act, CRA, ...', color: 'text-emerald-400' },
|
||||
{ name: de ? 'Compliance-Berater' : 'Compliance Advisor', soul: de ? 'Legal RAG + LLM' : 'Legal RAG + LLM', desc: de ? 'Chatbot mit 75+ Rechtsquellen als Wissenbasis' : 'Chatbot with 75+ legal sources as knowledge base', color: 'text-purple-400' },
|
||||
{ name: de ? 'Dokument-Generator' : 'Document Generator', soul: de ? '7+ Templates' : '7+ templates', desc: de ? 'AGB, DSE, AV-Vertrag, DSFA, FRIA, BV + weitere' : 'T&C, Privacy Policy, DPA, DPIA, FRIA, Works Agreement + more', color: 'text-amber-400' },
|
||||
{ name: de ? 'DSFA-Agent' : 'DPIA Agent', soul: de ? 'Art. 35 DSGVO' : 'Art. 35 GDPR', desc: de ? 'Risikobewertung mit 16 Bundesländer-Leitlinien' : 'Risk assessment with 16 federal state guidelines', color: 'text-red-400' },
|
||||
{ name: de ? 'Control-Pipeline' : 'Control Pipeline', soul: de ? '70.000+ Controls' : '70,000+ controls', desc: de ? 'Automatische Extraktion aus neuen Rechtsquellen' : 'Automatic extraction from new legal sources', color: 'text-blue-400' },
|
||||
]
|
||||
|
||||
const agentInfra = [
|
||||
{ icon: Shield, label: de ? 'Policy Engine' : 'Policy Engine', desc: de ? 'Deterministisch · LLM ist NICHT Wahrheitsquelle' : 'Deterministic · LLM is NOT source of truth' },
|
||||
{ icon: Brain, label: de ? 'LLM-Schicht' : 'LLM Layer', desc: de ? '120B (OVH) + 1000B (SysEleven BSI) · EU-only' : '120B (OVH) + 1000B (SysEleven BSI) · EU-only' },
|
||||
{ icon: Brain, label: de ? 'LLM-Schicht' : 'LLM Layer', desc: de ? 'Claude + lokale Modelle · EU-only Hosting' : 'Claude + local models · EU-only hosting' },
|
||||
{ icon: Network, label: 'LiteLLM Gateway', desc: de ? 'OpenAI-kompatibel · Multi-Provider Routing' : 'OpenAI-compatible · Multi-provider routing' },
|
||||
{ icon: Activity, label: de ? 'Eskalation E0–E3' : 'Escalation E0–E3', desc: de ? 'Auto-Approve → Team-Lead → DSB → DSB+Legal' : 'Auto-approve → Team lead → DPO → DPO+Legal' },
|
||||
]
|
||||
@@ -113,32 +175,32 @@ export default function AIPipelineSlide({ lang }: AIPipelineSlideProps) {
|
||||
color: 'text-emerald-400',
|
||||
title: de ? 'Control Quality Pipeline' : 'Control Quality Pipeline',
|
||||
items: de
|
||||
? ['6.259 Controls extrahiert (79% Source-Match)', '3.301 Duplikate entfernt (Phase 5 Normalisierung)', '90+ QA-Skripte: Deduplizierung, Match-Validierung', 'Canonical Controls JSON-Schema-Validierung in CI']
|
||||
: ['6,259 controls extracted (79% source match)', '3,301 duplicates removed (Phase 5 normalization)', '90+ QA scripts: deduplication, match validation', 'Canonical Controls JSON schema validation in CI'],
|
||||
? ['97.000 Controls generiert, 70.000+ nach Deduplizierung', '6 Pipeline-Versionen mit steigender Extraktionsqualität', 'Automatische Lizenz-Prüfung: geschützte Normen werden reformuliert', 'Jeder Control hat Quellen-Referenz auf Artikel und Absatz']
|
||||
: ['97,000 controls generated, 70,000+ after deduplication', '6 pipeline versions with increasing extraction quality', 'Automatic license check: protected standards are reformulated', 'Every control has source reference to article and paragraph'],
|
||||
},
|
||||
{
|
||||
icon: Eye,
|
||||
color: 'text-indigo-400',
|
||||
title: de ? 'RAG Quality & Monitoring' : 'RAG Quality & Monitoring',
|
||||
title: de ? 'Kontinuierliche Erweiterung' : 'Continuous Expansion',
|
||||
items: de
|
||||
? ['PDF-QA-Pipeline: 86% Artikel-Extraktion', 'Multi-Collection-Whitelist-Validierung', 'Qdrant-Deduplizierung: 8-Stufen-Bereinigung', 'Fallback-Handling: RAG-Fehler brechen nie Hauptfunktion']
|
||||
: ['PDF QA pipeline: 86% article extraction', 'Multi-collection whitelist validation', 'Qdrant deduplication: 8-step cleanup', 'Fallback handling: RAG failures never break main function'],
|
||||
? ['Neue Gesetze werden automatisch ingestiert und verarbeitet', 'Pipeline erkennt Überschneidungen mit bestehenden Controls', 'Cross-Regulation Mapping: gleiche Pflicht aus DSGVO und BDSG wird verknuepft', 'Wachsender Wissensvorsprung gegenüber manueller Compliance-Beratung']
|
||||
: ['New laws are automatically ingested and processed', 'Pipeline detects overlaps with existing controls', 'Cross-regulation mapping: same obligation from GDPR and BDSG is linked', 'Growing knowledge advantage over manual compliance consulting'],
|
||||
},
|
||||
{
|
||||
icon: Sparkles,
|
||||
color: 'text-purple-400',
|
||||
title: de ? 'CI/CD & Testing' : 'CI/CD & Testing',
|
||||
items: de
|
||||
? ['Gitea Actions: Lint → Tests → Validierung bei jedem Push', 'Go-Tests (AI SDK) + Python-Tests (Backend + Crawler + Gateway)', 'Coolify Auto-Deploy mit Health-Check-Monitoring', 'arm64 → amd64 Cross-Build fuer Hetzner Production']
|
||||
: ['Gitea Actions: Lint → Tests → Validation on every push', 'Go tests (AI SDK) + Python tests (Backend + Crawler + Gateway)', 'Coolify auto-deploy with health check monitoring', 'arm64 → amd64 cross-build for Hetzner production'],
|
||||
? ['Gitea Actions: Lint → Tests → Validierung bei jedem Push', 'Go-Tests (AI SDK) + Python-Tests (Backend + Pipeline)', 'Orca Auto-Deploy mit Health-Check-Monitoring', 'arm64 → amd64 Cross-Build für Hetzner Production']
|
||||
: ['Gitea Actions: Lint → Tests → Validation on every push', 'Go tests (AI SDK) + Python tests (Backend + Pipeline)', 'Orca auto-deploy with health check monitoring', 'arm64 → amd64 cross-build for Hetzner production'],
|
||||
},
|
||||
{
|
||||
icon: Zap,
|
||||
color: 'text-amber-400',
|
||||
title: de ? 'LLM-Infrastruktur' : 'LLM Infrastructure',
|
||||
title: de ? 'Infrastruktur' : 'Infrastructure',
|
||||
items: de
|
||||
? ['120B Modell auf OVH via LiteLLM (OpenAI-kompatibel)', '1000B Modell auf SysEleven (BSI-zertifiziert)', 'Isolierte Namespaces pro Kunde · Keine US-Provider', 'BGE-M3 Embedding lokal · Lazy Model Loading']
|
||||
: ['120B model on OVH via LiteLLM (OpenAI-compatible)', '1000B model on SysEleven (BSI-certified)', 'Isolated namespaces per customer · No US providers', 'BGE-M3 embedding local · Lazy model loading'],
|
||||
? ['Qdrant Vektordatenbank für semantische Suche', 'BGE-M3 Multilingual Embedding (lokal gehostet)', 'MinIO Object Storage (S3-kompatibel, TLS-verschlüsselt)', '100% EU-Cloud · Keine US-Provider · BSI-konforme Hosting-Partner']
|
||||
: ['Qdrant vector database for semantic search', 'BGE-M3 multilingual embedding (locally hosted)', 'MinIO object storage (S3-compatible, TLS-encrypted)', '100% EU cloud · No US providers · BSI-compliant hosting partners'],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -179,7 +241,7 @@ export default function AIPipelineSlide({ lang }: AIPipelineSlideProps) {
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-sm transition-all
|
||||
${activeTab === tab.id
|
||||
? 'bg-indigo-500/20 text-indigo-300 border border-indigo-500/30'
|
||||
: 'bg-white/[0.04] text-white/40 border border-transparent hover:text-white/60 hover:bg-white/[0.06]'
|
||||
: 'bg-white/[0.04] text-white/40 border border-transparent hover:text-white/60 hover:bg-white/[0.06] animate-[pulse_3s_ease-in-out_infinite]'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
@@ -194,15 +256,32 @@ export default function AIPipelineSlide({ lang }: AIPipelineSlideProps) {
|
||||
<FadeInView delay={0.2} key={activeTab}>
|
||||
{activeTab === 'rag' && (
|
||||
<div>
|
||||
{/* Source Categories — Why each matters */}
|
||||
<div className="grid md:grid-cols-2 gap-2 mb-4">
|
||||
{sourceCategories.map((cat, idx) => {
|
||||
const Icon = cat.icon
|
||||
return (
|
||||
<div key={idx} className={`border rounded-xl p-2.5 ${cat.bg}`}>
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<Icon className={`w-4 h-4 ${cat.color}`} />
|
||||
<h3 className="text-xs font-bold text-white">{cat.title}</h3>
|
||||
</div>
|
||||
<p className="text-[10px] text-white/50 mb-1.5 leading-relaxed">{cat.why}</p>
|
||||
<p className="text-[9px] text-white/25 font-mono leading-tight">{cat.examples}</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Pipeline Flow Visualization */}
|
||||
<div className="flex items-center justify-center gap-1 mb-4 flex-wrap">
|
||||
<div className="flex items-center justify-center gap-1 flex-wrap">
|
||||
{[
|
||||
{ icon: FileText, label: '38+ PDFs' },
|
||||
{ icon: Layers, label: 'QA + Chunking' },
|
||||
{ icon: Cpu, label: 'BGE-M3' },
|
||||
{ icon: Database, label: '6 Collections' },
|
||||
{ icon: Search, label: 'Hybrid Search' },
|
||||
{ icon: Brain, label: '120B / 1000B' },
|
||||
{ icon: FileText, label: de ? '75+ Quellen' : '75+ Sources' },
|
||||
{ icon: Layers, label: de ? 'Chunking & Lizenz' : 'Chunking & License' },
|
||||
{ icon: Cpu, label: de ? 'LLM-Extraktion' : 'LLM Extraction' },
|
||||
{ icon: Database, label: de ? '70k+ Controls' : '70k+ Controls' },
|
||||
{ icon: Search, label: de ? 'Hybrid Search' : 'Hybrid Search' },
|
||||
{ icon: Brain, label: de ? 'Beratung' : 'Advisory' },
|
||||
].map((step, idx, arr) => (
|
||||
<div key={idx} className="flex items-center gap-1">
|
||||
<div className="flex items-center gap-1 px-2 py-1 rounded-lg bg-white/[0.05] border border-white/[0.08]">
|
||||
@@ -213,28 +292,6 @@ export default function AIPipelineSlide({ lang }: AIPipelineSlideProps) {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Pipeline Steps */}
|
||||
<div className="grid md:grid-cols-2 gap-3">
|
||||
{ragPipelineSteps.map((step, idx) => {
|
||||
const Icon = step.icon
|
||||
return (
|
||||
<div key={idx} className={`border rounded-xl p-3 ${step.bg}`}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Icon className={`w-4 h-4 ${step.color}`} />
|
||||
<h3 className="text-xs font-bold text-white">{step.title}</h3>
|
||||
</div>
|
||||
<ul className="space-y-1">
|
||||
{step.items.map((item, iidx) => (
|
||||
<li key={iidx} className="flex items-start gap-1.5 text-[11px] text-white/50">
|
||||
<span className={`w-1 h-1 rounded-full mt-1.5 ${step.color} bg-current shrink-0`} />
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -291,7 +348,7 @@ export default function AIPipelineSlide({ lang }: AIPipelineSlideProps) {
|
||||
<div className="mt-3 pt-3 border-t border-white/5">
|
||||
<p className="text-[10px] text-white/20">
|
||||
{de
|
||||
? 'Wahrheit = Regeln + Evidenz · LLM = Uebersetzer + Subsumtions-Helfer · 100% EU-Cloud'
|
||||
? 'Wahrheit = Regeln + Evidenz · LLM = Übersetzer + Subsumtions-Helfer · 100% EU-Cloud'
|
||||
: 'Truth = Rules + Evidence · LLM = Translator + Subsumption Helper · 100% EU Cloud'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,129 +1,733 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useRef, Fragment } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Language } from '@/lib/types'
|
||||
import { t } from '@/lib/i18n'
|
||||
import GradientText from '../ui/GradientText'
|
||||
import FadeInView from '../ui/FadeInView'
|
||||
import GlassCard from '../ui/GlassCard'
|
||||
import { Server, Cpu, Shield, Database, Globe, Lock, Layers, Workflow } from 'lucide-react'
|
||||
import {
|
||||
Brain, Shield, ScanLine, Zap, Cpu,
|
||||
Layers, Wrench, X, Users, Lock,
|
||||
Server, BadgeCheck,
|
||||
} from 'lucide-react'
|
||||
|
||||
interface ArchitectureSlideProps {
|
||||
lang: Language
|
||||
interface ArchitectureSlideProps { lang: Language }
|
||||
type NodeId = 'certifai' | 'complai' | 'scanner' | 'litellm' | 'llm' | 'embeddings' | 'tools'
|
||||
|
||||
interface NodeDef {
|
||||
id: NodeId
|
||||
icon: React.ElementType
|
||||
title: string
|
||||
subtitle: string
|
||||
color: string
|
||||
tech: string[]
|
||||
services: { name: string; desc: string }[]
|
||||
primary?: boolean
|
||||
tier: 'product' | 'proxy' | 'inference'
|
||||
}
|
||||
|
||||
function getNodes(de: boolean): NodeDef[] {
|
||||
return [
|
||||
{
|
||||
id: 'certifai', icon: Brain,
|
||||
title: 'CERTifAI',
|
||||
subtitle: de ? 'GenAI Mandantenportal' : 'GenAI Tenant Portal',
|
||||
color: '#c084fc', tier: 'product',
|
||||
tech: ['Rust', 'Dioxus', 'MongoDB', 'Keycloak', 'SearXNG', 'LangGraph'],
|
||||
services: [
|
||||
{ name: 'LiteLLM Dashboard', desc: de ? 'Modellverwaltung & Kostentracking' : 'Model mgmt & cost tracking' },
|
||||
{ name: 'LibreChat + SSO', desc: de ? 'Mandanten-Chat mit Keycloak' : 'Tenant chat with Keycloak' },
|
||||
{ name: 'LangGraph Agents', desc: de ? 'Agent-Orchestrierung' : 'Agent orchestration' },
|
||||
{ name: 'MCP Hub', desc: de ? 'Tool-Integration für KI-Clients' : 'Tool integration for AI clients' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'complai', icon: Shield,
|
||||
title: 'COMPLAI',
|
||||
subtitle: de ? 'Compliance & Audit' : 'Compliance & Audit',
|
||||
color: '#818cf8', tier: 'product',
|
||||
tech: ['Next.js 15', 'FastAPI', 'Go/Gin', 'PostgreSQL', 'Qdrant', 'Valkey'],
|
||||
services: [
|
||||
{ name: de ? 'DSGVO / AI Act / NIS2' : 'GDPR / AI Act / NIS2', desc: de ? '70k+ auditierbare Controls' : '70k+ auditable controls' },
|
||||
{ name: 'RAG Pipeline', desc: de ? '75+ Rechtsquellen, semantische Suche' : '75+ legal sources, semantic search' },
|
||||
{ name: 'Control Pipeline', desc: de ? 'Gesetzestextanalyse via LLM' : 'Legal text analysis via LLM' },
|
||||
{ name: 'MCP Client', desc: de ? 'Echtzeit-Findings vom Scanner' : 'Real-time findings from Scanner' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'scanner', icon: ScanLine,
|
||||
title: 'Compliance Scanner',
|
||||
subtitle: de ? 'Code-Sicherheit' : 'Code Security',
|
||||
color: '#34d399', tier: 'product',
|
||||
tech: ['Rust', 'Axum', 'MongoDB', 'Semgrep', 'Gitleaks', 'Syft'],
|
||||
services: [
|
||||
{ name: 'SAST / SBOM / CVE', desc: de ? 'Vollautomatische Pipeline' : 'Fully automated pipeline' },
|
||||
{ name: de ? 'KI-Triage' : 'AI Triage', desc: de ? 'LLM filtert False Positives' : 'LLM filters false positives' },
|
||||
{ name: de ? 'KI-Pentest' : 'AI Pentest', desc: de ? 'Autonome Angriffsketten' : 'Autonomous attack chains' },
|
||||
{ name: 'MCP Server', desc: de ? 'Live-Findings für COMPLAI' : 'Live findings for COMPLAI' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'litellm', icon: Zap,
|
||||
title: 'LiteLLM Proxy',
|
||||
subtitle: de ? 'KI-Gateway & Guardrails' : 'AI Gateway & Guardrails',
|
||||
color: '#fbbf24', tier: 'proxy', primary: true,
|
||||
tech: ['OpenAI-kompatible API', 'Bearer Auth', 'Rate Limiting', 'PII-Filter', 'Spend Tracking'],
|
||||
services: [
|
||||
{ name: de ? 'Token-Budget' : 'Token Budget', desc: de ? 'Pro-Mandant Kontingente & Abrechnung' : 'Per-tenant quotas & billing' },
|
||||
{ name: 'PII Guardrails', desc: de ? 'Datenschutz-Filter für alle Anfragen' : 'Privacy filter on all requests' },
|
||||
{ name: de ? 'Web-Suche (anonym)' : 'Web Search (anon)', desc: de ? 'SearXNG-Proxy, kein US-Anbieter' : 'SearXNG proxy, no US providers' },
|
||||
{ name: de ? 'Namespace-Isolierung' : 'Namespace Isolation', desc: de ? 'Mandantentrennung per API-Key' : 'Tenant isolation per API key' },
|
||||
{ name: de ? 'Failover-Routing' : 'Failover Routing', desc: de ? 'Automatisches Fallback' : 'Automatic fallback between models' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'llm', icon: Cpu,
|
||||
title: de ? 'LLM Inferenz' : 'LLM Inference',
|
||||
subtitle: de ? 'Lokale Sprachmodelle' : 'Local Language Models',
|
||||
color: '#60a5fa', tier: 'inference',
|
||||
tech: ['Qwen3-32B', 'Qwen3-Coder-30B', 'DeepSeek-R1-8B', 'Ollama'],
|
||||
services: [
|
||||
{ name: de ? 'Vollständig lokal' : 'Fully local', desc: de ? 'Daten verlassen nie den Server' : 'Data never leaves the server' },
|
||||
{ name: de ? 'Air-Gap fähig' : 'Air-Gap Capable', desc: de ? 'Kein Internet erforderlich' : 'No internet required' },
|
||||
{ name: de ? 'GPU-optimiert' : 'GPU-optimized', desc: de ? 'Dedizierte Inferenz-Hardware' : 'Dedicated inference hardware' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'embeddings', icon: Layers,
|
||||
title: 'Embeddings',
|
||||
subtitle: de ? 'Semantische Suche' : 'Semantic Search',
|
||||
color: '#a78bfa', tier: 'inference',
|
||||
tech: ['bge-m3', 'Qdrant Vector DB', 'Sentence-Transformers'],
|
||||
services: [
|
||||
{ name: 'RAG Pipeline', desc: de ? '75+ Rechtsquellen indexiert' : '75+ legal sources indexed' },
|
||||
{ name: de ? 'Semantische Suche' : 'Semantic Search', desc: de ? 'Multi-linguale Einbettungen' : 'Multi-lingual embeddings' },
|
||||
{ name: de ? 'Lokal' : 'Fully local', desc: de ? 'Keine externen APIs' : 'No external APIs' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'tools', icon: Wrench,
|
||||
title: de ? 'KI-Tools' : 'AI Tools',
|
||||
subtitle: de ? 'Web-Suche & MCP' : 'Web Search & MCP',
|
||||
color: '#2dd4bf', tier: 'inference',
|
||||
tech: ['SearXNG', 'MCP Protocol', 'Semgrep API', 'Gitleaks API'],
|
||||
services: [
|
||||
{ name: 'SearXNG', desc: de ? 'Anonymisierte EU-Websuche' : 'Anonymized EU web search' },
|
||||
{ name: 'MCP Tools', desc: de ? 'Auditdokumente & Code-Findings' : 'Audit docs & code findings' },
|
||||
{ name: de ? 'Kein US-Anbieter' : 'No US providers', desc: de ? '100% DSGVO-konform' : '100% GDPR-compliant' },
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const LAYERS: { id: string; nodeIds: NodeId[]; tint: string; depth: number }[] = [
|
||||
{ id: 'product', nodeIds: ['certifai', 'complai', 'scanner'], tint: '#a78bfa', depth: 24 },
|
||||
{ id: 'proxy', nodeIds: ['litellm'], tint: '#fbbf24', depth: 12 },
|
||||
{ id: 'inference', nodeIds: ['llm', 'embeddings', 'tools'], tint: '#8b5cf6', depth: 0 },
|
||||
]
|
||||
|
||||
const CSS_KF = `
|
||||
@keyframes v4FlowDown { from { stroke-dashoffset: 0 } to { stroke-dashoffset: -18px } }
|
||||
@keyframes v4Pulse { 0%,100% { opacity:1;transform:scale(1) } 50% { opacity:.4;transform:scale(1.4) } }
|
||||
@keyframes v4Caret { 0%,50% { opacity:1 } 51%,100% { opacity:0 } }
|
||||
@keyframes v4DotFall {
|
||||
0% { transform: translateY(-5px); opacity: 0; }
|
||||
12% { opacity: 1; }
|
||||
88% { opacity: 1; }
|
||||
100% { transform: translateY(38px); opacity: 0; }
|
||||
}
|
||||
`
|
||||
|
||||
const MONO: React.CSSProperties = {
|
||||
fontFamily: '"JetBrains Mono","SF Mono",ui-monospace,monospace',
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
}
|
||||
|
||||
// ── Theme detection ───────────────────────────────────────────────────────────
|
||||
function useIsLight() {
|
||||
const [isLight, setIsLight] = useState(false)
|
||||
useEffect(() => {
|
||||
const check = () => setIsLight(document.documentElement.classList.contains('theme-light'))
|
||||
check()
|
||||
const obs = new MutationObserver(check)
|
||||
obs.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
|
||||
return () => obs.disconnect()
|
||||
}, [])
|
||||
return isLight
|
||||
}
|
||||
|
||||
// ── Ticker primitives ─────────────────────────────────────────────────────────
|
||||
function useTicker(fn: () => void, min = 140, max = 360, skipChance = 0.1) {
|
||||
const ref = useRef(fn)
|
||||
ref.current = fn
|
||||
useEffect(() => {
|
||||
let tid: ReturnType<typeof setTimeout>
|
||||
const loop = () => {
|
||||
if (Math.random() > skipChance) ref.current()
|
||||
tid = setTimeout(loop, min + Math.random() * (max - min))
|
||||
}
|
||||
loop()
|
||||
return () => clearTimeout(tid)
|
||||
}, [min, max, skipChance])
|
||||
}
|
||||
|
||||
function TickerShell({ color, children, isLight }: { color: string; children: React.ReactNode; isLight: boolean }) {
|
||||
return (
|
||||
<div style={{
|
||||
...MONO,
|
||||
marginTop: 7, padding: '5px 9px',
|
||||
background: isLight ? '#f1f5f9' : 'rgba(0,0,0,.38)',
|
||||
border: `1px solid ${color}${isLight ? '55' : '55'}`, borderRadius: 6,
|
||||
fontSize: 10, color: isLight ? '#475569' : 'rgba(236,233,247,.88)',
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
whiteSpace: 'nowrap', overflow: 'hidden', height: 22,
|
||||
}}>{children}</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Caret({ color }: { color: string }) {
|
||||
return (
|
||||
<span style={{
|
||||
display: 'inline-block', width: 5, height: 9, marginLeft: -2,
|
||||
background: color, animation: 'v4Caret 1s step-end infinite',
|
||||
}} />
|
||||
)
|
||||
}
|
||||
|
||||
// ── Per-node tickers ──────────────────────────────────────────────────────────
|
||||
function TickCertifAI({ color, isLight }: { color: string; isLight: boolean }) {
|
||||
const [n, setN] = useState(8421)
|
||||
const [hash, setHash] = useState('9f3a…e10b')
|
||||
const pool = 'abcdef0123456789'
|
||||
const r = (k: number) => Array.from({ length: k }, () => pool[Math.floor(Math.random() * pool.length)]).join('')
|
||||
useTicker(() => { setN(v => v + 1); setHash(`${r(4)}…${r(4)}`) }, 1000, 2000, 0.1)
|
||||
return (
|
||||
<TickerShell color={color} isLight={isLight}>
|
||||
<span style={{ color: isLight ? '#16a34a' : '#4ade80' }}>✓</span>
|
||||
<span style={{ color, opacity: .85 }}>sig</span>
|
||||
<span style={{ color: isLight ? '#1a1a2e' : '#f5f3fc', fontWeight: 600 }}>{n.toLocaleString()}</span>
|
||||
<span style={{ color: isLight ? '#94a3b8' : 'rgba(236,233,247,.55)' }}>{hash}</span>
|
||||
</TickerShell>
|
||||
)
|
||||
}
|
||||
|
||||
function TickComplAI({ color, isLight }: { color: string; isLight: boolean }) {
|
||||
const [evals, setEvals] = useState(1284)
|
||||
const [rate, setRate] = useState(99.2)
|
||||
useTicker(() => {
|
||||
setEvals(v => v + 1 + Math.floor(Math.random() * 3))
|
||||
setRate(r => Math.max(97, Math.min(99.9, r + (Math.random() - 0.5) * 0.4)))
|
||||
}, 200, 500, 0.1)
|
||||
return (
|
||||
<TickerShell color={color} isLight={isLight}>
|
||||
<span style={{ color: isLight ? '#16a34a' : '#4ade80' }}>●</span>
|
||||
<span style={{ color, opacity: .85 }}>eval</span>
|
||||
<span style={{ color: isLight ? '#1a1a2e' : '#f5f3fc', fontWeight: 600 }}>{evals.toLocaleString()}</span>
|
||||
<span style={{ color: isLight ? '#94a3b8' : 'rgba(236,233,247,.45)' }}>pass</span>
|
||||
<span style={{ color: isLight ? '#16a34a' : '#4ade80' }}>{rate.toFixed(1)}%</span>
|
||||
</TickerShell>
|
||||
)
|
||||
}
|
||||
|
||||
function TickScanner({ color, isLight }: { color: string; isLight: boolean }) {
|
||||
const lines = [
|
||||
{ k: 'PASS', c: '#16a34a', cd: '#4ade80', t: 'CWE-79 xss check' },
|
||||
{ k: 'WARN', c: '#d97706', cd: '#fbbf24', t: 'drift: model v2.1→2.2' },
|
||||
{ k: 'PASS', c: '#16a34a', cd: '#4ade80', t: 'bias: demographic parity' },
|
||||
{ k: 'FAIL', c: '#dc2626', cd: '#f87171', t: 'license: GPL-3 detected' },
|
||||
{ k: 'PASS', c: '#16a34a', cd: '#4ade80', t: 'prompt-inject: 214 vectors' },
|
||||
{ k: 'SCAN', c: '#7c3aed', cd: '#a78bfa', t: 'artifact model-card.json' },
|
||||
]
|
||||
const [i, setI] = useState(0)
|
||||
useTicker(() => setI(x => (x + 1) % lines.length), 700, 1200, 0.05)
|
||||
const l = lines[i]
|
||||
return (
|
||||
<TickerShell color={color} isLight={isLight}>
|
||||
<span style={{ color: isLight ? l.c : l.cd, fontWeight: 600, minWidth: 30 }}>{l.k}</span>
|
||||
<span style={{ color: isLight ? '#334155' : 'rgba(236,233,247,.85)', overflow: 'hidden', textOverflow: 'ellipsis', flex: 1 }}>{l.t}</span>
|
||||
</TickerShell>
|
||||
)
|
||||
}
|
||||
|
||||
function TickLiteLLM({ color, isLight }: { color: string; isLight: boolean }) {
|
||||
const [rps, setRps] = useState(428)
|
||||
const [p50, setP50] = useState(84)
|
||||
useTicker(() => {
|
||||
setRps(v => Math.max(200, Math.min(800, v + (Math.random() - 0.5) * 60)))
|
||||
setP50(v => Math.max(40, Math.min(160, v + (Math.random() - 0.5) * 20)))
|
||||
}, 250, 500, 0.05)
|
||||
return (
|
||||
<TickerShell color={color} isLight={isLight}>
|
||||
<span style={{ color: '#d97706' }}>⚡</span>
|
||||
<span style={{ color, opacity: .9 }}>req/s</span>
|
||||
<span style={{ color: isLight ? '#1a1a2e' : '#f5f3fc', fontWeight: 600 }}>{Math.round(rps)}</span>
|
||||
<span style={{ color: isLight ? '#94a3b8' : 'rgba(236,233,247,.4)' }}>·</span>
|
||||
<span style={{ color, opacity: .9 }}>p50</span>
|
||||
<span style={{ color: isLight ? '#1a1a2e' : '#f5f3fc', fontWeight: 600 }}>{Math.round(p50)}ms</span>
|
||||
<Caret color={color} />
|
||||
</TickerShell>
|
||||
)
|
||||
}
|
||||
|
||||
function TickLLM({ color, isLight }: { color: string; isLight: boolean }) {
|
||||
const [tokens, setTokens] = useState(14832)
|
||||
const [stream, setStream] = useState('t_a91f')
|
||||
const pool = 'abcdef0123456789'
|
||||
useTicker(() => {
|
||||
setTokens(v => v + 1 + Math.floor(Math.random() * 5))
|
||||
setStream('t_' + Array.from({ length: 4 }, () => pool[Math.floor(Math.random() * pool.length)]).join(''))
|
||||
}, 120, 340, 0.15)
|
||||
return (
|
||||
<TickerShell color={color} isLight={isLight}>
|
||||
<span style={{ color: isLight ? '#16a34a' : '#4ade80' }}>●</span>
|
||||
<span style={{ color, opacity: .85 }}>tok</span>
|
||||
<span style={{ color: isLight ? '#1a1a2e' : '#f5f3fc', fontWeight: 600 }}>{tokens.toLocaleString()}</span>
|
||||
<span style={{ color: isLight ? '#94a3b8' : 'rgba(236,233,247,.35)' }}>↑</span>
|
||||
<span style={{ color }}>{stream}</span>
|
||||
<Caret color={color} />
|
||||
</TickerShell>
|
||||
)
|
||||
}
|
||||
|
||||
function TickEmbeddings({ color, isLight }: { color: string; isLight: boolean }) {
|
||||
const [vecs, setVecs] = useState(284112)
|
||||
useTicker(() => setVecs(v => v + 1 + Math.floor(Math.random() * 8)), 180, 420, 0.1)
|
||||
return (
|
||||
<TickerShell color={color} isLight={isLight}>
|
||||
<span style={{ color: isLight ? '#16a34a' : '#4ade80' }}>●</span>
|
||||
<span style={{ color, opacity: .85 }}>idx</span>
|
||||
<span style={{ color: isLight ? '#1a1a2e' : '#f5f3fc', fontWeight: 600 }}>{vecs.toLocaleString()}</span>
|
||||
<span style={{ color: isLight ? '#94a3b8' : 'rgba(236,233,247,.4)' }}>· 1024d</span>
|
||||
<Caret color={color} />
|
||||
</TickerShell>
|
||||
)
|
||||
}
|
||||
|
||||
function TickTools({ color, isLight }: { color: string; isLight: boolean }) {
|
||||
const ops = [
|
||||
'search("BSI C5 controls")', 'fetch eur-lex.europa.eu',
|
||||
'grep -r "DSGVO"', 'read docs/policy.md',
|
||||
'mcp.call(filesystem)', 'search("vLLM 0.6 release")',
|
||||
]
|
||||
const [i, setI] = useState(0)
|
||||
useTicker(() => setI(x => (x + 1) % ops.length), 900, 1600, 0.05)
|
||||
return (
|
||||
<TickerShell color={color} isLight={isLight}>
|
||||
<span style={{ color: isLight ? '#16a34a' : '#4ade80' }}>●</span>
|
||||
<span style={{ color, opacity: .85 }}>call</span>
|
||||
<span style={{ color: isLight ? '#334155' : '#f5f3fc', overflow: 'hidden', textOverflow: 'ellipsis', flex: 1 }}>{ops[i]}</span>
|
||||
</TickerShell>
|
||||
)
|
||||
}
|
||||
|
||||
const NODE_TICKER: Record<NodeId, React.ComponentType<{ color: string; isLight: boolean }>> = {
|
||||
certifai: TickCertifAI,
|
||||
complai: TickComplAI,
|
||||
scanner: TickScanner,
|
||||
litellm: TickLiteLLM,
|
||||
llm: TickLLM,
|
||||
embeddings: TickEmbeddings,
|
||||
tools: TickTools,
|
||||
}
|
||||
|
||||
// ── Animated connector ────────────────────────────────────────────────────────
|
||||
function LayerConnector({ tint }: { tint: string }) {
|
||||
const tracks = [
|
||||
{ x: '32%', primary: false },
|
||||
{ x: '50%', primary: true },
|
||||
{ x: '68%', primary: false },
|
||||
]
|
||||
return (
|
||||
<div style={{ position: 'relative', height: 34, width: '100%', maxWidth: 960, margin: '0 auto' }}>
|
||||
{tracks.map(({ x, primary }, ti) => {
|
||||
const color = primary ? '#fbbf24' : tint
|
||||
const dots = primary ? 4 : 3
|
||||
const dur = primary ? 1.6 : 2.4
|
||||
return (
|
||||
<div key={ti} style={{ position: 'absolute', left: x, top: 0, bottom: 0, transform: 'translateX(-50%)' }}>
|
||||
<div style={{
|
||||
position: 'absolute', left: -0.75, top: 0, bottom: 0, width: 1.5,
|
||||
background: `linear-gradient(180deg, ${color}00, ${color}55 40%, ${color}55 60%, ${color}00)`,
|
||||
}} />
|
||||
{Array.from({ length: dots }, (_, j) => (
|
||||
<div key={j} style={{
|
||||
position: 'absolute', top: 0, left: -3, width: 6, height: 6, borderRadius: '50%',
|
||||
background: color, boxShadow: `0 0 7px ${color}`,
|
||||
animation: `v4DotFall ${dur}s ${-(j / dots) * dur}s linear infinite`,
|
||||
}} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Node card ─────────────────────────────────────────────────────────────────
|
||||
function NodeCard({ node, selected, onClick, isLight }: {
|
||||
node: NodeDef; selected: boolean; onClick: () => void; isLight: boolean
|
||||
}) {
|
||||
const [hover, setHover] = useState(false)
|
||||
const active = hover || selected
|
||||
const c = node.color
|
||||
const Ticker = NODE_TICKER[node.id]
|
||||
const Icon = node.icon
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
onMouseEnter={() => setHover(true)}
|
||||
onMouseLeave={() => setHover(false)}
|
||||
style={{
|
||||
flex: 1,
|
||||
background: active
|
||||
? `linear-gradient(180deg, ${c}${isLight ? '20' : '33'}, ${c}${isLight ? '0a' : '12'})`
|
||||
: isLight
|
||||
? 'linear-gradient(180deg, #ffffff, #f8fafc)'
|
||||
: 'linear-gradient(180deg, rgba(255,255,255,.055), rgba(255,255,255,.015))',
|
||||
border: `1px solid ${active ? c : isLight ? 'rgba(0,0,0,.1)' : 'rgba(255,255,255,.14)'}`,
|
||||
borderRadius: 12, padding: '12px 14px',
|
||||
cursor: 'pointer', textAlign: 'left',
|
||||
color: isLight ? '#1a1a2e' : '#ece9f7', fontFamily: 'inherit',
|
||||
display: 'flex', flexDirection: 'column',
|
||||
transition: 'all .2s ease',
|
||||
transform: active ? 'translateY(-1px)' : 'none',
|
||||
boxShadow: active
|
||||
? `0 8px 26px ${c}44, 0 0 0 4px ${c}14`
|
||||
: isLight ? '0 1px 4px rgba(0,0,0,.06)' : '0 1px 0 rgba(255,255,255,.04)',
|
||||
minWidth: 0, position: 'relative',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<div style={{
|
||||
width: 36, height: 36, borderRadius: 10, flexShrink: 0,
|
||||
background: `linear-gradient(135deg, ${c}3a, ${c}10)`,
|
||||
border: `1px solid ${c}66`,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: c,
|
||||
boxShadow: node.primary ? `inset 0 0 14px ${c}40` : 'none',
|
||||
}}>
|
||||
<Icon style={{ width: 18, height: 18 }} />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{
|
||||
fontSize: 13, fontWeight: 600,
|
||||
color: isLight ? '#1a1a2e' : '#f7f5fc',
|
||||
letterSpacing: -0.1, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
|
||||
}}>{node.title}</div>
|
||||
<div style={{
|
||||
fontSize: 10.5,
|
||||
color: isLight ? '#64748b' : 'rgba(236,233,247,.65)',
|
||||
marginTop: 1, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
|
||||
}}>{node.subtitle}</div>
|
||||
</div>
|
||||
</div>
|
||||
<Ticker color={c} isLight={isLight} />
|
||||
{node.primary && (
|
||||
<div style={{
|
||||
position: 'absolute', top: -1, right: -1,
|
||||
width: 6, height: 6, borderRadius: '50%',
|
||||
background: '#fbbf24', boxShadow: '0 0 8px #fbbf24',
|
||||
animation: 'v4Pulse 1.6s ease-in-out infinite',
|
||||
}} />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 3D slab ───────────────────────────────────────────────────────────────────
|
||||
function LayerSlab({ label, sublabel, nodes, tint, depth, selectedId, onSelect, isLight }: {
|
||||
label: string; sublabel: string; nodes: NodeDef[]
|
||||
tint: string; depth: number
|
||||
selectedId: NodeId | null; onSelect: (id: NodeId) => void
|
||||
isLight: boolean
|
||||
}) {
|
||||
const isProxy = nodes.length === 1 && !!nodes[0].primary
|
||||
return (
|
||||
<div style={{
|
||||
position: 'relative', margin: '0 auto',
|
||||
padding: '14px 20px 18px', width: '100%', maxWidth: 960,
|
||||
background: isLight
|
||||
? `linear-gradient(180deg, ${tint}18 0%, ${tint}08 60%, rgba(248,250,252,.98) 100%)`
|
||||
: `linear-gradient(180deg, ${tint}26 0%, ${tint}12 60%, rgba(14,8,28,.85) 100%)`,
|
||||
border: `1px solid ${tint}${isLight ? '44' : '66'}`,
|
||||
borderRadius: 16,
|
||||
boxShadow: isLight
|
||||
? `0 -2px 16px ${tint}18, 0 8px 30px rgba(0,0,0,.05), inset 0 1px 0 ${tint}44`
|
||||
: `0 -6px 30px ${tint}22, 0 24px 60px rgba(0,0,0,.6), inset 0 1px 0 ${tint}55, inset 0 -1px 0 rgba(0,0,0,.4)`,
|
||||
transform: `perspective(2000px) rotateX(12deg) translateZ(${depth}px)`,
|
||||
}}>
|
||||
<div style={{
|
||||
position: 'absolute', top: 0, left: 20, right: 20, height: 1,
|
||||
background: `linear-gradient(90deg, transparent, ${tint}${isLight ? 'aa' : 'cc'}, transparent)`,
|
||||
pointerEvents: 'none',
|
||||
}} />
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 10 }}>
|
||||
<div style={{
|
||||
fontSize: 9.5, letterSpacing: 2, textTransform: 'uppercase' as const, fontWeight: 600,
|
||||
color: tint,
|
||||
background: isLight ? `${tint}18` : `${tint}20`,
|
||||
padding: '3px 9px', borderRadius: 99,
|
||||
border: `1px solid ${tint}${isLight ? '44' : '50'}`, whiteSpace: 'nowrap',
|
||||
}}>{label}</div>
|
||||
<div style={{
|
||||
fontSize: 11,
|
||||
color: isLight ? '#64748b' : 'rgba(236,233,247,.55)',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>{sublabel}</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 10, justifyContent: isProxy ? 'center' : undefined }}>
|
||||
{isProxy ? (
|
||||
<div style={{ width: '42%', minWidth: 260, display: 'flex' }}>
|
||||
<NodeCard node={nodes[0]} selected={selectedId === nodes[0].id} onClick={() => onSelect(nodes[0].id)} isLight={isLight} />
|
||||
</div>
|
||||
) : (
|
||||
nodes.map(n => (
|
||||
<NodeCard key={n.id} node={n} selected={selectedId === n.id} onClick={() => onSelect(n.id)} isLight={isLight} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main slide ────────────────────────────────────────────────────────────────
|
||||
export default function ArchitectureSlide({ lang }: ArchitectureSlideProps) {
|
||||
const i = t(lang)
|
||||
const de = lang === 'de'
|
||||
const isLight = useIsLight()
|
||||
const allNodes = getNodes(de)
|
||||
const nodeMap = Object.fromEntries(allNodes.map(n => [n.id, n])) as Record<NodeId, NodeDef>
|
||||
|
||||
const layers = [
|
||||
{
|
||||
icon: Server,
|
||||
color: 'text-indigo-400',
|
||||
bg: 'bg-indigo-500/10 border-indigo-500/20',
|
||||
title: de ? 'Hardware-Schicht' : 'Hardware Layer',
|
||||
items: [
|
||||
{ label: 'ComplAI Mini', desc: 'Mac Mini M4 · 16 GB · Llama 3.2 3B' },
|
||||
{ label: 'ComplAI Studio', desc: 'Mac Studio M4 Max · 64 GB · Qwen 2.5 32B' },
|
||||
{ label: 'ComplAI Cloud', desc: de ? 'Managed GPU-Cluster · Multi-Model' : 'Managed GPU Cluster · Multi-Model' },
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: Cpu,
|
||||
color: 'text-purple-400',
|
||||
bg: 'bg-purple-500/10 border-purple-500/20',
|
||||
title: de ? 'KI-Engine' : 'AI Engine',
|
||||
items: [
|
||||
{ label: 'Ollama Runtime', desc: de ? 'Lokale LLM-Inferenz, GPU-optimiert' : 'Local LLM inference, GPU-optimized' },
|
||||
{ label: 'RAG Pipeline', desc: de ? 'Vektorsuche mit Compliance-Wissensbasis' : 'Vector search with compliance knowledge base' },
|
||||
{ label: 'Agent Framework', desc: de ? 'Autonome Compliance-Agenten (Audit, Monitoring, Reporting)' : 'Autonomous compliance agents (Audit, Monitoring, Reporting)' },
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: Shield,
|
||||
color: 'text-emerald-400',
|
||||
bg: 'bg-emerald-500/10 border-emerald-500/20',
|
||||
title: de ? 'Compliance-Module' : 'Compliance Modules',
|
||||
items: [
|
||||
{ label: 'DSGVO Engine', desc: de ? 'VVT, DSFA, Betroffenenrechte, Loeschkonzept' : 'RoPA, DPIA, Data Subject Rights, Deletion Concept' },
|
||||
{ label: 'AI Act Module', desc: de ? 'Risikoklassifizierung, Konformitaetsbewertung, Dokumentation' : 'Risk Classification, Conformity Assessment, Documentation' },
|
||||
{ label: 'NIS2 Module', desc: de ? 'Cybersecurity-Policies, Incident Response, Meldewege' : 'Cybersecurity Policies, Incident Response, Reporting Chains' },
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: Layers,
|
||||
color: 'text-blue-400',
|
||||
bg: 'bg-blue-500/10 border-blue-500/20',
|
||||
title: de ? 'Plattform-Services' : 'Platform Services',
|
||||
items: [
|
||||
{ label: de ? 'Admin-Dashboard' : 'Admin Dashboard', desc: 'Next.js · ' + (de ? 'Mandantenfaehig · Rollenbasiert' : 'Multi-Tenant · Role-Based') },
|
||||
{ label: 'SDK API', desc: 'Go/Gin · REST · ' + (de ? 'Tenant-isoliert' : 'Tenant-Isolated') },
|
||||
{ label: 'DevSecOps Suite', desc: 'Semgrep · Trivy · Gitleaks · CycloneDX SBOM' },
|
||||
],
|
||||
},
|
||||
]
|
||||
const [activeId, setActiveId] = useState<NodeId | null>(null)
|
||||
function toggle(id: NodeId) { setActiveId(prev => prev === id ? null : id) }
|
||||
const active = activeId ? nodeMap[activeId] : null
|
||||
|
||||
const securityFeatures = [
|
||||
{ icon: Lock, label: de ? 'Zero-Trust Architektur' : 'Zero-Trust Architecture' },
|
||||
{ icon: Database, label: de ? 'Daten verlassen nie das Unternehmen' : 'Data Never Leaves the Company' },
|
||||
{ icon: Globe, label: de ? 'Kein Cloud-Abhaengigkeit' : 'No Cloud Dependency' },
|
||||
{ icon: Workflow, label: de ? 'Air-Gap faehig' : 'Air-Gap Capable' },
|
||||
]
|
||||
const tenants = de
|
||||
? ['Mandant A', 'Mandant B', 'Mandant C', 'Mandant N…']
|
||||
: ['Namespace A', 'Namespace B', 'Namespace C', 'Namespace N…']
|
||||
|
||||
const layerLabels = de
|
||||
? ['01 · Anwendung', '02 · Gateway', '03 · Infrastruktur']
|
||||
: ['01 · Application', '02 · Gateway', '03 · Infrastructure']
|
||||
const layerSublabels = de
|
||||
? ['Benutzeroberflächen', 'Routing & Guardrails', 'Compute & Daten']
|
||||
: ['User-facing services', 'Routing & guardrails', 'Compute & data']
|
||||
|
||||
return (
|
||||
<div>
|
||||
<FadeInView className="text-center mb-8">
|
||||
<p className="text-xs font-mono text-indigo-400/60 uppercase tracking-widest mb-2">
|
||||
<div className="space-y-3">
|
||||
<style>{CSS_KF}</style>
|
||||
|
||||
<FadeInView className="text-center mb-3">
|
||||
<p className="text-[10px] font-mono text-indigo-400/50 uppercase tracking-widest mb-1.5">
|
||||
{de ? 'Anhang' : 'Appendix'}
|
||||
</p>
|
||||
<h2 className="text-4xl md:text-5xl font-bold mb-3">
|
||||
<h2 className="text-3xl md:text-4xl font-bold mb-1.5">
|
||||
<GradientText>{i.annex.architecture.title}</GradientText>
|
||||
</h2>
|
||||
<p className="text-lg text-white/50 max-w-2xl mx-auto">{i.annex.architecture.subtitle}</p>
|
||||
<p className="text-xs text-white/35">
|
||||
{de ? 'Klicke auf eine Station für Details' : 'Click any node to explore'}
|
||||
</p>
|
||||
</FadeInView>
|
||||
|
||||
{/* Architecture Layers */}
|
||||
<div className="grid md:grid-cols-2 gap-4 mb-6">
|
||||
{layers.map((layer, idx) => {
|
||||
const Icon = layer.icon
|
||||
return (
|
||||
<FadeInView key={idx} delay={0.2 + idx * 0.1}>
|
||||
<div className={`border rounded-xl p-4 ${layer.bg}`}>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Icon className={`w-5 h-5 ${layer.color}`} />
|
||||
<h3 className="text-sm font-bold text-white">{layer.title}</h3>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{layer.items.map((item, iidx) => (
|
||||
<div key={iidx} className="flex items-start gap-2">
|
||||
<div className={`w-1.5 h-1.5 rounded-full mt-1.5 ${layer.color} bg-current opacity-50`} />
|
||||
<div>
|
||||
<span className="text-xs font-semibold text-white/80">{item.label}</span>
|
||||
<span className="text-xs text-white/40 ml-2">{item.desc}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</FadeInView>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<FadeInView delay={0.15}>
|
||||
<div className="flex items-center justify-center gap-2 flex-wrap mb-3 px-[4%]">
|
||||
<Users className="w-3 h-3 text-white/25 flex-shrink-0" />
|
||||
<span className="text-[9px] font-mono text-white/25 uppercase tracking-widest mr-1">
|
||||
{de ? 'Kundenmandanten' : 'Customer Namespaces'}
|
||||
</span>
|
||||
{tenants.map(tn => (
|
||||
<span key={tn} className="text-[9px] px-2 py-0.5 rounded-full border border-white/[0.08] bg-white/[0.03] text-white/35 font-mono">
|
||||
{tn}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Security Bar */}
|
||||
<FadeInView delay={0.6}>
|
||||
<GlassCard hover={false} className="p-4">
|
||||
<div className="flex items-center justify-center gap-8 flex-wrap">
|
||||
{securityFeatures.map((feat, idx) => {
|
||||
const Icon = feat.icon
|
||||
{/* ── Main canvas ── */}
|
||||
<div style={{
|
||||
position: 'relative',
|
||||
background: isLight
|
||||
? 'linear-gradient(180deg, #f0f4ff 0%, #eef2ff 50%, #f0f4ff 100%)'
|
||||
: 'linear-gradient(180deg, #0a0618 0%, #140a28 50%, #1a0f34 100%)',
|
||||
borderRadius: 16, overflow: 'hidden',
|
||||
padding: '22px 16px 20px',
|
||||
fontFamily: '"Inter", system-ui, -apple-system, sans-serif',
|
||||
WebkitFontSmoothing: 'antialiased',
|
||||
MozOsxFontSmoothing: 'grayscale',
|
||||
} as React.CSSProperties}>
|
||||
|
||||
{/* Ambient glows */}
|
||||
{!isLight && (
|
||||
<>
|
||||
<div style={{
|
||||
position: 'absolute', top: -80, left: '25%',
|
||||
width: 400, height: 400, borderRadius: '50%',
|
||||
background: 'radial-gradient(circle, rgba(167,139,250,.2), transparent 65%)',
|
||||
filter: 'blur(50px)', pointerEvents: 'none',
|
||||
}} />
|
||||
<div style={{
|
||||
position: 'absolute', bottom: -100, right: '15%',
|
||||
width: 500, height: 500, borderRadius: '50%',
|
||||
background: 'radial-gradient(circle, rgba(139,92,246,.15), transparent 65%)',
|
||||
filter: 'blur(50px)', pointerEvents: 'none',
|
||||
}} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Slabs + connectors */}
|
||||
<div style={{
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
||||
position: 'relative', zIndex: 1,
|
||||
perspective: '2000px', perspectiveOrigin: '50% 0%',
|
||||
}}>
|
||||
{LAYERS.map((layer, li) => {
|
||||
const nodes = layer.nodeIds.map(id => nodeMap[id])
|
||||
return (
|
||||
<div key={idx} className="flex items-center gap-2">
|
||||
<Icon className="w-4 h-4 text-emerald-400" />
|
||||
<span className="text-xs text-white/60">{feat.label}</span>
|
||||
</div>
|
||||
<Fragment key={layer.id}>
|
||||
<LayerSlab
|
||||
label={layerLabels[li]}
|
||||
sublabel={layerSublabels[li]}
|
||||
nodes={nodes}
|
||||
tint={layer.tint}
|
||||
depth={layer.depth}
|
||||
selectedId={activeId}
|
||||
onSelect={toggle}
|
||||
isLight={isLight}
|
||||
/>
|
||||
{li < LAYERS.length - 1 && <LayerConnector tint={layer.tint} />}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
{/* Footer badges */}
|
||||
<div style={{
|
||||
display: 'flex', justifyContent: 'center', gap: 8,
|
||||
flexWrap: 'wrap', marginTop: 20, position: 'relative', zIndex: 1,
|
||||
}}>
|
||||
{([
|
||||
{ Icon: Lock, label: de ? 'Kein US-Anbieter · 100% DSGVO' : 'No US providers · 100% GDPR' },
|
||||
{ Icon: Server, label: de ? 'BSI-zertifiziertes Rechenzentrum' : 'BSI-certified data center' },
|
||||
{ Icon: BadgeCheck, label: de ? 'EU-souveräne Inferenz' : 'EU-sovereign inference' },
|
||||
] as { Icon: React.ElementType; label: string }[]).map(({ Icon, label }) => (
|
||||
<div key={label} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
padding: '5px 11px', borderRadius: 99,
|
||||
background: isLight ? '#ffffff' : 'rgba(10,6,24,.82)',
|
||||
border: `1px solid ${isLight ? 'rgba(0,0,0,.1)' : 'rgba(167,139,250,.28)'}`,
|
||||
fontSize: 10.5,
|
||||
color: isLight ? '#64748b' : 'rgba(236,233,247,.7)',
|
||||
whiteSpace: 'nowrap',
|
||||
boxShadow: isLight ? '0 1px 3px rgba(0,0,0,.06)' : 'none',
|
||||
}}>
|
||||
<Icon style={{ width: 12, height: 12, color: '#a78bfa' }} />
|
||||
{label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Detail panel */}
|
||||
<AnimatePresence>
|
||||
{active && (
|
||||
<motion.div
|
||||
initial={{ y: '100%' }}
|
||||
animate={{ y: 0 }}
|
||||
exit={{ y: '100%' }}
|
||||
transition={{ duration: 0.3, ease: [0.2, 0.7, 0.2, 1] }}
|
||||
style={{
|
||||
position: 'absolute', left: 0, right: 0, bottom: 0,
|
||||
background: isLight ? 'rgba(255,255,255,.98)' : 'rgba(15,10,31,.97)',
|
||||
borderTop: `1px solid ${active.color}${isLight ? '30' : '40'}`,
|
||||
zIndex: 50,
|
||||
padding: '18px 24px 20px',
|
||||
boxShadow: isLight ? '0 -8px 30px rgba(0,0,0,.08)' : '0 -20px 60px rgba(0,0,0,.55)',
|
||||
}}
|
||||
>
|
||||
<div style={{ maxWidth: 900, margin: '0 auto' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16, marginBottom: 14 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{
|
||||
width: 38, height: 38, borderRadius: 11, flexShrink: 0,
|
||||
background: `linear-gradient(135deg, ${active.color}3a, ${active.color}10)`,
|
||||
border: `1px solid ${active.color}66`,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: active.color,
|
||||
}}>
|
||||
<active.icon style={{ width: 19, height: 19 }} />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<span style={{ fontSize: 15, fontWeight: 600, color: isLight ? '#1a1a2e' : '#f5f3fc', letterSpacing: -0.2 }}>
|
||||
{active.title}
|
||||
</span>
|
||||
<span style={{
|
||||
fontSize: 9, padding: '2px 7px', borderRadius: 4,
|
||||
background: `${active.color}18`, color: active.color,
|
||||
border: `1px solid ${active.color}40`,
|
||||
letterSpacing: 0.8, textTransform: 'uppercase' as const, fontWeight: 600,
|
||||
}}>
|
||||
{active.tier === 'product' ? (de ? 'Anwendung' : 'Application') :
|
||||
active.tier === 'proxy' ? 'Gateway' :
|
||||
(de ? 'Inferenz' : 'Inference')}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 11.5, color: isLight ? '#64748b' : 'rgba(236,233,247,.5)', marginTop: 2 }}>
|
||||
{active.subtitle}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setActiveId(null)}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: `1px solid ${isLight ? 'rgba(0,0,0,.15)' : 'rgba(167,139,250,.25)'}`,
|
||||
color: isLight ? '#64748b' : 'rgba(236,233,247,.5)',
|
||||
width: 28, height: 28, borderRadius: 14,
|
||||
cursor: 'pointer', display: 'flex',
|
||||
alignItems: 'center', justifyContent: 'center', flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<X style={{ width: 13, height: 13 }} />
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 20 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 8.5, letterSpacing: 1.5, textTransform: 'uppercase' as const, color: isLight ? '#94a3b8' : 'rgba(236,233,247,.32)', marginBottom: 7, fontWeight: 600 }}>
|
||||
{de ? 'Stack' : 'Tech Stack'}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5 }}>
|
||||
{active.tech.map(tk => (
|
||||
<span key={tk} style={{
|
||||
...MONO,
|
||||
fontSize: 10, padding: '3px 8px', borderRadius: 5,
|
||||
background: isLight ? '#f1f5f9' : 'rgba(255,255,255,.05)',
|
||||
border: `1px solid ${isLight ? 'rgba(0,0,0,.1)' : 'rgba(255,255,255,.1)'}`,
|
||||
color: isLight ? '#334155' : 'rgba(236,233,247,.65)',
|
||||
}}>{tk}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 8.5, letterSpacing: 1.5, textTransform: 'uppercase' as const, color: isLight ? '#94a3b8' : 'rgba(236,233,247,.32)', marginBottom: 7, fontWeight: 600 }}>
|
||||
{de ? 'Funktionen' : 'Capabilities'}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 5 }}>
|
||||
{active.services.map(s => (
|
||||
<div key={s.name} style={{ display: 'flex', alignItems: 'baseline', gap: 6 }}>
|
||||
<div style={{ width: 3, height: 3, borderRadius: '50%', background: active.color, opacity: 0.7, flexShrink: 0, marginTop: 6 }} />
|
||||
<span style={{ fontSize: 11.5, fontWeight: 600, color: isLight ? '#1a1a2e' : 'rgba(245,243,252,.82)' }}>{s.name}</span>
|
||||
<span style={{ fontSize: 10, color: isLight ? '#64748b' : 'rgba(236,233,247,.38)' }}>{s.desc}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</FadeInView>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Language } from '@/lib/types'
|
||||
import { t } from '@/lib/i18n'
|
||||
import GradientText from '../ui/GradientText'
|
||||
@@ -9,94 +10,135 @@ import { TrendingUp, TrendingDown, Minus } from 'lucide-react'
|
||||
|
||||
interface AssumptionsSlideProps {
|
||||
lang: Language
|
||||
investorId?: string | null
|
||||
preferredScenarioId?: string | null
|
||||
isWandeldarlehen?: boolean
|
||||
}
|
||||
|
||||
export default function AssumptionsSlide({ lang }: AssumptionsSlideProps) {
|
||||
interface SheetRow {
|
||||
row_label?: string
|
||||
values?: Record<string, number>
|
||||
values_total?: Record<string, number>
|
||||
}
|
||||
|
||||
interface ScenarioKPIs {
|
||||
arr: number
|
||||
customers: number
|
||||
headcount: number
|
||||
cash: number
|
||||
breakEvenYear: string
|
||||
}
|
||||
|
||||
function fmtArr(v: number, de: boolean): string {
|
||||
if (v >= 1_000_000) return de ? `~${(v / 1_000_000).toFixed(1).replace('.', ',')} Mio. EUR` : `~EUR ${(v / 1_000_000).toFixed(1)}M`
|
||||
if (v >= 1000) return de ? `~${Math.round(v / 1000)}k EUR` : `~EUR ${Math.round(v / 1000)}k`
|
||||
return de ? `~${v} EUR` : `~EUR ${v}`
|
||||
}
|
||||
|
||||
function fmtCash(v: number, de: boolean): string {
|
||||
if (Math.abs(v) >= 1_000_000) return de ? `~${(v / 1_000_000).toFixed(1).replace('.', ',')} Mio. EUR` : `~EUR ${(v / 1_000_000).toFixed(1)}M`
|
||||
return de ? `~${Math.round(v / 1000)}k EUR` : `~EUR ${Math.round(v / 1000)}k`
|
||||
}
|
||||
|
||||
async function loadScenarioKPIs(scenarioId: string | null): Promise<ScenarioKPIs> {
|
||||
const param = scenarioId ? `?scenarioId=${scenarioId}` : ''
|
||||
try {
|
||||
const [guvRes, liqRes, persRes, kundenRes] = await Promise.all([
|
||||
fetch(`/api/finanzplan/guv${param}`, { cache: 'no-store' }),
|
||||
fetch(`/api/finanzplan/liquiditaet${param}`, { cache: 'no-store' }),
|
||||
fetch(`/api/finanzplan/personalkosten${param}`, { cache: 'no-store' }),
|
||||
fetch(`/api/finanzplan/kunden${param}`, { cache: 'no-store' }),
|
||||
])
|
||||
const [guv, liq, pers, kunden] = await Promise.all([guvRes.json(), liqRes.json(), persRes.json(), kundenRes.json()])
|
||||
const findGuv = (label: string) => (guv.rows || []).find((r: SheetRow) => (r.row_label || '').includes(label))
|
||||
const findLiq = (label: string) => (liq.rows || []).find((r: SheetRow) => (r.row_label || '').includes(label))
|
||||
const kundenGesamt = (kunden.rows || []).find((r: SheetRow) => r.row_label === 'Bestandskunden gesamt')
|
||||
|
||||
const ebit = findGuv('EBIT')?.values || {}
|
||||
let breakEvenYear = '—'
|
||||
for (const y of [2026, 2027, 2028, 2029, 2030]) {
|
||||
if ((ebit[`y${y}`] || 0) > 0) { breakEvenYear = String(y); break }
|
||||
}
|
||||
|
||||
return {
|
||||
arr: findGuv('Umsatzerlöse')?.values?.y2030 || 0,
|
||||
customers: kundenGesamt?.values?.m60 || 0,
|
||||
headcount: (pers.rows || []).filter((r: SheetRow) => ((r.values_total || r.values)?.m60 || 0) > 0).length,
|
||||
cash: findLiq('LIQUIDIT')?.values?.m60 || findLiq('LIQUIDITAET')?.values?.m60 || 0,
|
||||
breakEvenYear,
|
||||
}
|
||||
} catch {
|
||||
return { arr: 0, customers: 0, headcount: 0, cash: 0, breakEvenYear: '—' }
|
||||
}
|
||||
}
|
||||
|
||||
export default function AssumptionsSlide({ lang, isWandeldarlehen }: AssumptionsSlideProps) {
|
||||
const i = t(lang)
|
||||
const de = lang === 'de'
|
||||
|
||||
// 3 Cases abgeleitet aus dem Finanzplan (Base Case = aktuelle DB-Daten)
|
||||
const cases = [
|
||||
{
|
||||
name: 'Bear Case',
|
||||
icon: TrendingDown,
|
||||
color: 'text-red-400',
|
||||
bg: 'bg-red-500/10 border-red-500/20',
|
||||
desc: de ? 'Langsames Wachstum, höhere Churn' : 'Slow growth, higher churn',
|
||||
assumptions: de ? [
|
||||
'Kundenwachstum 50% langsamer als Base',
|
||||
'Churn Rate 8% pro Monat (Startups)',
|
||||
'Durchschnittspreis 20% niedriger',
|
||||
'Personalaufbau verzögert um 6 Monate',
|
||||
'Serverkosten 150€ pro Kunde',
|
||||
const [scenarioData, setScenarioData] = useState<{ bear: ScenarioKPIs; base: ScenarioKPIs; bull: ScenarioKPIs } | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
const baseId = isWandeldarlehen ? 'c0000000-0000-0000-0000-000000000200' : null
|
||||
const bearId = isWandeldarlehen ? 'd0000000-0000-0000-0000-000000000201' : 'd0000000-0000-0000-0000-000000000301'
|
||||
const bullId = isWandeldarlehen ? 'd0000000-0000-0000-0000-000000000202' : 'd0000000-0000-0000-0000-000000000302'
|
||||
const [bear, base, bull] = await Promise.all([loadScenarioKPIs(bearId), loadScenarioKPIs(baseId), loadScenarioKPIs(bullId)])
|
||||
setScenarioData({ bear, base, bull })
|
||||
}
|
||||
load()
|
||||
}, [isWandeldarlehen])
|
||||
|
||||
const bear = scenarioData?.bear || { arr: 0, customers: 0, headcount: 0, cash: 0, breakEvenYear: '—' }
|
||||
const base = scenarioData?.base || { arr: 0, customers: 0, headcount: 0, cash: 0, breakEvenYear: '—' }
|
||||
const bull = scenarioData?.bull || { arr: 0, customers: 0, headcount: 0, cash: 0, breakEvenYear: '—' }
|
||||
|
||||
const baseAssumptions = isWandeldarlehen
|
||||
? (de ? [
|
||||
'Kundenwachstum: 8% monatlich, ab m32: 15%',
|
||||
'Mix: 60% Starter, 25% Professional, 15% Enterprise',
|
||||
`Personalaufbau: 3→${base.headcount} Personen (lean)`,
|
||||
'Serverkosten: 50€/Kunde + 300€ Basis',
|
||||
] : [
|
||||
'Customer growth 50% slower than base',
|
||||
'Churn rate 8% per month (startups)',
|
||||
'Average price 20% lower',
|
||||
'Hiring delayed by 6 months',
|
||||
'Server costs €150 per customer',
|
||||
],
|
||||
kpis: {
|
||||
kunden2030: '~600',
|
||||
arr2030: de ? '~4,2 Mio. EUR' : '~EUR 4.2M',
|
||||
ma2030: '25',
|
||||
breakEven: '2030',
|
||||
cash2030: de ? '~0,5 Mio. EUR' : '~EUR 0.5M',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Base Case',
|
||||
icon: Minus,
|
||||
color: 'text-indigo-400',
|
||||
bg: 'bg-indigo-500/10 border-indigo-500/20',
|
||||
desc: de ? 'Aktueller Finanzplan' : 'Current financial plan',
|
||||
assumptions: de ? [
|
||||
'Kundenwachstum wie geplant (14→1.200)',
|
||||
'Mix: 75% Startup, 15% KMU, 7% Mittel, 3% Enterprise',
|
||||
'Customer growth: 8% monthly, from m32: 15%',
|
||||
'Mix: 60% Starter, 25% Professional, 15% Enterprise',
|
||||
`Hiring: 3→${base.headcount} people (lean)`,
|
||||
'Server costs: €50/customer + €300 base',
|
||||
])
|
||||
: (de ? [
|
||||
'Kundenwachstum wie geplant (5→1.200+)',
|
||||
'Mix: 60% Starter, 25% Professional, 15% Enterprise',
|
||||
'Personalaufbau 5→10→17→25→35',
|
||||
'Serverkosten 100€ pro Kunde + 2.000€ Basis',
|
||||
'Break-Even Mitte 2029',
|
||||
] : [
|
||||
'Customer growth as planned (14→1,200)',
|
||||
'Mix: 75% startup, 15% SME, 7% mid, 3% enterprise',
|
||||
'Customer growth as planned (5→1,200+)',
|
||||
'Mix: 60% Starter, 25% Professional, 15% Enterprise',
|
||||
'Hiring 5→10→17→25→35',
|
||||
'Server costs €100 per customer + €2,000 base',
|
||||
'Break-even mid 2029',
|
||||
],
|
||||
kpis: {
|
||||
kunden2030: '~1.200',
|
||||
arr2030: de ? '~10 Mio. EUR' : '~EUR 10M',
|
||||
ma2030: '35',
|
||||
breakEven: '2029',
|
||||
cash2030: de ? '~6,4 Mio. EUR' : '~EUR 6.4M',
|
||||
},
|
||||
])
|
||||
|
||||
const cases = [
|
||||
{
|
||||
name: 'Bear Case', icon: TrendingDown, color: 'text-red-400', bg: 'bg-red-500/10 border-red-500/20',
|
||||
desc: de ? 'Langsames Wachstum, höhere Churn' : 'Slow growth, higher churn',
|
||||
assumptions: de
|
||||
? ['Kundenwachstum 40% langsamer', 'Churn Rate 50% höher', 'Personalaufbau wie Base Case']
|
||||
: ['Customer growth 40% slower', 'Churn rate 50% higher', 'Hiring same as base case'],
|
||||
kpis: bear,
|
||||
},
|
||||
{
|
||||
name: 'Bull Case',
|
||||
icon: TrendingUp,
|
||||
color: 'text-emerald-400',
|
||||
bg: 'bg-emerald-500/10 border-emerald-500/20',
|
||||
name: 'Base Case', icon: Minus, color: 'text-indigo-400', bg: 'bg-indigo-500/10 border-indigo-500/20',
|
||||
desc: de ? 'Aktueller Finanzplan' : 'Current financial plan',
|
||||
assumptions: baseAssumptions,
|
||||
kpis: base,
|
||||
},
|
||||
{
|
||||
name: 'Bull Case', icon: TrendingUp, color: 'text-emerald-400', bg: 'bg-emerald-500/10 border-emerald-500/20',
|
||||
desc: de ? 'Beschleunigtes Wachstum' : 'Accelerated growth',
|
||||
assumptions: de ? [
|
||||
'Kundenwachstum 50% schneller (Regulierungsdruck)',
|
||||
'Enterprise-Anteil steigt auf 8%',
|
||||
'Durchschnittspreis 15% höher (Upselling)',
|
||||
'Channel-Partner ab Q1/2027',
|
||||
'EU-Expansion ab 2028',
|
||||
] : [
|
||||
'Customer growth 50% faster (regulation pressure)',
|
||||
'Enterprise share rises to 8%',
|
||||
'Average price 15% higher (upselling)',
|
||||
'Channel partners from Q1/2027',
|
||||
'EU expansion from 2028',
|
||||
],
|
||||
kpis: {
|
||||
kunden2030: '~2.000',
|
||||
arr2030: de ? '~18 Mio. EUR' : '~EUR 18M',
|
||||
ma2030: '50',
|
||||
breakEven: '2028',
|
||||
cash2030: de ? '~15 Mio. EUR' : '~EUR 15M',
|
||||
},
|
||||
assumptions: de
|
||||
? ['Kundenwachstum 30-50% schneller', 'Churn Rate 30% niedriger', 'Personalaufbau wie Base Case']
|
||||
: ['Customer growth 30-50% faster', 'Churn rate 30% lower', 'Hiring same as base case'],
|
||||
kpis: bull,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -109,49 +151,45 @@ export default function AssumptionsSlide({ lang }: AssumptionsSlideProps) {
|
||||
<p className="text-sm text-white/50 max-w-2xl mx-auto">{i.annex.assumptions.subtitle}</p>
|
||||
</FadeInView>
|
||||
|
||||
{/* 3 Cases nebeneinander */}
|
||||
<div className="grid md:grid-cols-3 gap-4 mb-6">
|
||||
{cases.map((c, idx) => {
|
||||
const Icon = c.icon
|
||||
return (
|
||||
<GlassCard key={idx} delay={0.1 + idx * 0.1} hover={false} className={`p-4 border-t-2 ${c.bg}`}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Icon className={`w-5 h-5 ${c.color}`} />
|
||||
<h3 className={`text-sm font-bold ${c.color}`}>{c.name}</h3>
|
||||
</div>
|
||||
<p className="text-[10px] text-white/40 mb-3">{c.desc}</p>
|
||||
|
||||
{/* Annahmen */}
|
||||
<div className="space-y-1.5 mb-4">
|
||||
{c.assumptions.map((a, i) => (
|
||||
<p key={i} className="text-xs text-white/60 pl-3 relative">
|
||||
<span className={`absolute left-0 top-1 w-1.5 h-1.5 rounded-full ${c.color.replace('text-', 'bg-')}/60`} />
|
||||
{a}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* KPIs */}
|
||||
<div className="border-t border-white/10 pt-3 space-y-1.5">
|
||||
{[
|
||||
{ label: de ? 'Kunden 2030' : 'Customers 2030', value: c.kpis.kunden2030 },
|
||||
{ label: 'ARR 2030', value: c.kpis.arr2030 },
|
||||
{ label: de ? 'Mitarbeiter 2030' : 'Employees 2030', value: c.kpis.ma2030 },
|
||||
{ label: 'Break-Even', value: c.kpis.breakEven },
|
||||
{ label: 'Cash 2030', value: c.kpis.cash2030 },
|
||||
].map((kpi, i) => (
|
||||
<div key={i} className="flex justify-between text-xs">
|
||||
<span className="text-white/40">{kpi.label}</span>
|
||||
<span className={`font-mono font-bold ${c.color}`}>{kpi.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</GlassCard>
|
||||
<FadeInView key={idx} delay={0.1 + idx * 0.1}>
|
||||
<GlassCard hover={false} className={`p-4 h-full ${c.bg} border`}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Icon className={`w-5 h-5 ${c.color}`} />
|
||||
<h3 className={`text-base font-bold ${c.color}`}>{c.name}</h3>
|
||||
</div>
|
||||
<p className="text-xs text-white/40 mb-3">{c.desc}</p>
|
||||
<div className="space-y-1.5 mb-4">
|
||||
{c.assumptions.map((a, i) => (
|
||||
<p key={i} className="text-xs text-white/60 pl-3 relative">
|
||||
<span className={`absolute left-0 top-1 w-1.5 h-1.5 rounded-full ${c.color.replace('text-', 'bg-')}/60`} />
|
||||
{a}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
<div className="border-t border-white/10 pt-3 space-y-1.5">
|
||||
{[
|
||||
{ label: de ? 'Kunden 2030' : 'Customers 2030', value: `~${c.kpis.customers.toLocaleString('de-DE')}` },
|
||||
{ label: 'ARR 2030', value: fmtArr(c.kpis.arr, de) },
|
||||
{ label: de ? 'Mitarbeiter' : 'Employees', value: String(c.kpis.headcount) },
|
||||
{ label: 'Break-Even', value: c.kpis.breakEvenYear },
|
||||
{ label: 'Cash 2030', value: fmtCash(c.kpis.cash, de) },
|
||||
].map((kpi, i) => (
|
||||
<div key={i} className="flex justify-between text-xs">
|
||||
<span className="text-white/40">{kpi.label}</span>
|
||||
<span className={`font-mono font-bold ${c.color}`}>{kpi.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</GlassCard>
|
||||
</FadeInView>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Vergleichstabelle */}
|
||||
<FadeInView delay={0.5}>
|
||||
<GlassCard hover={false} className="p-4">
|
||||
<h3 className="text-xs font-bold text-white/40 uppercase tracking-wider mb-3">
|
||||
@@ -168,11 +206,11 @@ export default function AssumptionsSlide({ lang }: AssumptionsSlideProps) {
|
||||
</thead>
|
||||
<tbody>
|
||||
{[
|
||||
{ label: de ? 'Kunden' : 'Customers', bear: '~600', base: '~1.200', bull: '~2.000' },
|
||||
{ label: 'ARR', bear: de ? '~4,2 Mio.' : '~4.2M', base: de ? '~10 Mio.' : '~10M', bull: de ? '~18 Mio.' : '~18M' },
|
||||
{ label: de ? 'Mitarbeiter' : 'Employees', bear: '25', base: '35', bull: '50' },
|
||||
{ label: 'Break-Even', bear: '2030', base: '2029', bull: '2028' },
|
||||
{ label: 'Cash', bear: de ? '~0,5 Mio.' : '~0.5M', base: de ? '~6,4 Mio.' : '~6.4M', bull: de ? '~15 Mio.' : '~15M' },
|
||||
{ label: de ? 'Kunden' : 'Customers', bear: `~${bear.customers.toLocaleString('de-DE')}`, base: `~${base.customers.toLocaleString('de-DE')}`, bull: `~${bull.customers.toLocaleString('de-DE')}` },
|
||||
{ label: 'ARR', bear: fmtArr(bear.arr, de), base: fmtArr(base.arr, de), bull: fmtArr(bull.arr, de) },
|
||||
{ label: de ? 'Mitarbeiter' : 'Employees', bear: String(bear.headcount), base: String(base.headcount), bull: String(bull.headcount) },
|
||||
{ label: 'Break-Even', bear: bear.breakEvenYear, base: base.breakEvenYear, bull: bull.breakEvenYear },
|
||||
{ label: 'Cash', bear: fmtCash(bear.cash, de), base: fmtCash(base.cash, de), bull: fmtCash(bull.cash, de) },
|
||||
].map((row, idx) => (
|
||||
<tr key={idx} className="border-b border-white/[0.03]">
|
||||
<td className="py-1.5 text-white/60">{row.label}</td>
|
||||
|
||||
@@ -5,100 +5,101 @@ import { t } from '@/lib/i18n'
|
||||
import GradientText from '../ui/GradientText'
|
||||
import FadeInView from '../ui/FadeInView'
|
||||
import GlassCard from '../ui/GlassCard'
|
||||
import AnimatedCounter from '../ui/AnimatedCounter'
|
||||
import { Repeat, TrendingUp, PiggyBank, ShieldCheck, Clock, Users } from 'lucide-react'
|
||||
import { ArrowRight } from 'lucide-react'
|
||||
|
||||
interface BusinessModelSlideProps {
|
||||
lang: Language
|
||||
products?: unknown[]
|
||||
investorId?: string | null
|
||||
preferredScenarioId?: string | null
|
||||
isWandeldarlehen?: boolean
|
||||
}
|
||||
|
||||
export default function BusinessModelSlide({ lang }: BusinessModelSlideProps) {
|
||||
const i = t(lang)
|
||||
const de = lang === 'de'
|
||||
|
||||
const tiers = [
|
||||
{
|
||||
name: 'Starter',
|
||||
target: de ? 'Startups & Kleinstunternehmen' : 'Startups & Micro',
|
||||
employees: '< 10',
|
||||
price: de ? '3.600' : '3,600',
|
||||
period: de ? 'EUR/Jahr' : 'EUR/yr',
|
||||
features: de
|
||||
? ['Code Security (SAST/DAST)', 'Compliance-Dokumente', 'Consent Management', '1 Anwendung']
|
||||
: ['Code Security (SAST/DAST)', 'Compliance documents', 'Consent management', '1 application'],
|
||||
highlight: false,
|
||||
},
|
||||
{
|
||||
name: 'Professional',
|
||||
target: de ? 'KMU & Mittelstand' : 'SME & Mid-Market',
|
||||
employees: '10 – 250',
|
||||
price: de ? '15.000 – 40.000' : '15,000 – 40,000',
|
||||
period: de ? 'EUR/Jahr' : 'EUR/yr',
|
||||
features: de
|
||||
? ['Alle Module inkl. CE-Bewertung', 'Audit Manager End-to-End', 'AI Act Compliance (UCCA)', 'Unbegrenzte Anwendungen']
|
||||
: ['All modules incl. CE assessment', 'Audit Manager end-to-end', 'AI Act Compliance (UCCA)', 'Unlimited applications'],
|
||||
highlight: true,
|
||||
},
|
||||
{
|
||||
name: 'Enterprise',
|
||||
target: de ? 'Konzerne & OEMs' : 'Enterprises & OEMs',
|
||||
employees: '250+',
|
||||
price: de ? 'ab 50.000' : 'from 50,000',
|
||||
period: de ? 'EUR/Jahr' : 'EUR/yr',
|
||||
features: de
|
||||
? ['Dedizierte Instanz', 'Custom Integrationen (SAP, MES)', 'SLA & Priority Support', 'Tender Matching & RFQ-Prüfung']
|
||||
: ['Dedicated instance', 'Custom integrations (SAP, MES)', 'SLA & priority support', 'Tender matching & RFQ verification'],
|
||||
highlight: false,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<FadeInView className="text-center mb-10">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<FadeInView className="text-center mb-6">
|
||||
<h2 className="text-4xl md:text-5xl font-bold mb-3">
|
||||
<GradientText>{i.businessModel.title}</GradientText>
|
||||
</h2>
|
||||
<p className="text-lg text-white/50 max-w-2xl mx-auto">{i.businessModel.subtitle}</p>
|
||||
<p className="text-lg text-white/50 max-w-2xl mx-auto">
|
||||
{i.businessModel.subtitle}
|
||||
</p>
|
||||
</FadeInView>
|
||||
|
||||
{/* Key Metrics */}
|
||||
<div className="grid md:grid-cols-3 gap-4 mb-8">
|
||||
<GlassCard delay={0.2} className="text-center">
|
||||
<Repeat className="w-6 h-6 text-indigo-400 mx-auto mb-2" />
|
||||
<p className="text-sm text-white/50 mb-1">{i.businessModel.recurringRevenue}</p>
|
||||
<p className="text-2xl font-bold text-white">100% SaaS</p>
|
||||
<p className="text-xs text-white/30">{de ? 'Mitarbeiterbasiertes Pricing' : 'Employee-based pricing'}</p>
|
||||
</GlassCard>
|
||||
<GlassCard delay={0.3} className="text-center">
|
||||
<TrendingUp className="w-6 h-6 text-green-400 mx-auto mb-2" />
|
||||
<p className="text-sm text-white/50 mb-1">{i.businessModel.margin}</p>
|
||||
<p className="text-2xl font-bold text-white">>80%</p>
|
||||
<p className="text-xs text-white/30">{de ? 'Cloud-native, keine HW-Kosten' : 'Cloud-native, no HW costs'}</p>
|
||||
</GlassCard>
|
||||
<GlassCard delay={0.4} className="text-center">
|
||||
<PiggyBank className="w-6 h-6 text-amber-400 mx-auto mb-2" />
|
||||
<p className="text-sm text-white/50 mb-1">{de ? 'Kundenersparnis' : 'Customer Savings'}</p>
|
||||
<p className="text-2xl font-bold text-white">50.000+ EUR</p>
|
||||
<p className="text-xs text-white/30">{de ? 'pro Kunde pro Jahr' : 'per customer per year'}</p>
|
||||
</GlassCard>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{tiers.map((tier, idx) => (
|
||||
<FadeInView key={idx} delay={0.1 + idx * 0.1}>
|
||||
<GlassCard hover={false} className={`p-5 h-full ${tier.highlight ? 'border-slate-300/40 bg-gradient-to-b from-slate-200/[0.08] to-slate-400/[0.04] shadow-lg shadow-slate-300/10 ring-1 ring-slate-300/20' : ''}`}>
|
||||
<h3 className="text-lg font-bold text-white mb-0.5">{tier.name}</h3>
|
||||
<p className="text-sm text-white/40 mb-2">{tier.target}</p>
|
||||
<p className="text-xs text-white/30 mb-4">{tier.employees} {de ? 'Mitarbeiter' : 'employees'}</p>
|
||||
|
||||
<div className="mb-4">
|
||||
<span className="text-2xl font-black text-white">{tier.price}</span>
|
||||
<span className="text-sm text-white/40 ml-1">{tier.period}</span>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-2">
|
||||
{tier.features.map((f, i) => (
|
||||
<li key={i} className="flex items-start gap-2 text-sm text-white/50">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-indigo-400 mt-1.5 shrink-0" />
|
||||
{f}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</GlassCard>
|
||||
</FadeInView>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Savings Breakdown — the core argument */}
|
||||
<FadeInView delay={0.5}>
|
||||
<GlassCard hover={false} className="p-6">
|
||||
<h3 className="text-lg font-semibold mb-5 text-white/70 text-center">
|
||||
{de ? 'ROI-Rechnung: Kunde mit 250+ Mitarbeitern' : 'ROI Calculation: Customer with 250+ employees'}
|
||||
</h3>
|
||||
<div className="grid md:grid-cols-2 gap-8">
|
||||
{/* Customer pays */}
|
||||
<div>
|
||||
<h4 className="text-xs font-bold text-indigo-400 uppercase tracking-wider mb-3">
|
||||
{i.businessModel.customerPays}
|
||||
</h4>
|
||||
<div className="bg-indigo-500/10 border border-indigo-500/20 rounded-xl p-4">
|
||||
<p className="text-3xl font-bold text-white text-center mb-1">
|
||||
<AnimatedCounter target={40} suffix="-50k" duration={1500} /> EUR
|
||||
</p>
|
||||
<p className="text-xs text-white/40 text-center">{de ? 'pro Jahr, modular waehlbar' : 'per year, modular choice'}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* Customer saves */}
|
||||
<div>
|
||||
<h4 className="text-xs font-bold text-emerald-400 uppercase tracking-wider mb-3">
|
||||
{i.businessModel.customerSaves}
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
{ label: i.businessModel.savingsPentest, amount: '30.000', icon: ShieldCheck },
|
||||
{ label: i.businessModel.savingsCE, amount: '20.000', icon: Clock },
|
||||
{ label: i.businessModel.savingsAudit, amount: '60.000+', icon: Users },
|
||||
].map((item, idx) => (
|
||||
<div key={idx} className="flex items-center justify-between bg-emerald-500/10 rounded-lg px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<item.icon className="w-3.5 h-3.5 text-emerald-400" />
|
||||
<span className="text-xs text-white/70">{item.label}</span>
|
||||
</div>
|
||||
<span className="text-xs font-bold text-emerald-300">{item.amount} EUR</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center justify-between bg-emerald-500/20 border border-emerald-500/30 rounded-lg px-3 py-2">
|
||||
<span className="text-xs font-bold text-white">{i.businessModel.savingsTotal}</span>
|
||||
<span className="text-sm font-bold text-emerald-300">50.000 - 110.000+ EUR</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-center text-xs text-white/30 mt-4">
|
||||
{de
|
||||
? '+ Strafvermeidung, Echtzeit-Kundenanfragen, kein Auditmanager noetig'
|
||||
: '+ penalty avoidance, real-time customer inquiries, no audit manager needed'}
|
||||
</p>
|
||||
</GlassCard>
|
||||
{/* Expansion arrow */}
|
||||
<FadeInView delay={0.4} className="flex items-center justify-center gap-3 mt-4">
|
||||
<span className="text-xs text-white/30">Starter</span>
|
||||
<ArrowRight className="w-3.5 h-3.5 text-indigo-400/40" />
|
||||
<span className="text-xs text-white/30">Professional</span>
|
||||
<ArrowRight className="w-3.5 h-3.5 text-indigo-400/40" />
|
||||
<span className="text-xs text-white/30">Enterprise</span>
|
||||
<span className="text-sm text-white/20 ml-3">{de ? 'Land & Expand' : 'Land & Expand'}</span>
|
||||
</FadeInView>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { Language } from '@/lib/types'
|
||||
import ProjectionFooter from '../ui/ProjectionFooter'
|
||||
import GradientText from '../ui/GradientText'
|
||||
import FadeInView from '../ui/FadeInView'
|
||||
import GlassCard from '../ui/GlassCard'
|
||||
@@ -17,9 +18,9 @@ export default function CapTableSlide({ lang }: CapTableSlideProps) {
|
||||
const de = lang === 'de'
|
||||
|
||||
const capTableData = [
|
||||
{ name: 'Benjamin Bönisch (CEO)', value: 37.5, color: '#6366f1' },
|
||||
{ name: 'Sharang Parnerkar (CTO)', value: 37.5, color: '#8b5cf6' },
|
||||
{ name: de ? 'Pre-Seed Investor' : 'Pre-Seed Investor', value: 19.6, color: '#f59e0b' },
|
||||
{ name: 'Benjamin Bönisch (CEO)', value: 37.3, color: '#6366f1' },
|
||||
{ name: 'Sharang Parnerkar (CTO)', value: 37.3, color: '#8b5cf6' },
|
||||
{ name: de ? 'Pre-Seed Investor' : 'Pre-Seed Investor', value: 20.0, color: '#f59e0b' },
|
||||
{ name: 'ESOP Pool', value: 5.4, color: '#94a3b8' },
|
||||
]
|
||||
|
||||
@@ -30,7 +31,7 @@ export default function CapTableSlide({ lang }: CapTableSlideProps) {
|
||||
<GradientText>{de ? 'Investition & Cap Table' : 'Investment & Cap Table'}</GradientText>
|
||||
</h2>
|
||||
<p className="text-sm text-white/50 max-w-2xl mx-auto">
|
||||
{de ? '4 Mio. EUR Pre-Money Bewertung · 975.000 EUR Pre-Seed · Gründung Jul/Aug 2026' : 'EUR 4M pre-money valuation · EUR 975,000 pre-seed · Founding Jul/Aug 2026'}
|
||||
{de ? '4 Mio. EUR Pre-Money Bewertung · 1.000.000 EUR Pre-Seed · Gründung Aug 2026' : 'EUR 4M pre-money valuation · EUR 1,000,000 pre-seed · Founding Aug 2026'}
|
||||
</p>
|
||||
</FadeInView>
|
||||
|
||||
@@ -79,10 +80,9 @@ export default function CapTableSlide({ lang }: CapTableSlideProps) {
|
||||
<div className="space-y-3">
|
||||
{[
|
||||
{ label: 'Pre-Money Bewertung', value: '4.000.000 EUR', icon: Target },
|
||||
{ label: 'Investment', value: '975.000 EUR', icon: Briefcase },
|
||||
{ label: 'Post-Money', value: '4.975.000 EUR', icon: TrendingUp },
|
||||
{ label: de ? 'Investoranteil' : 'Investor Share', value: '19,6%', icon: Users },
|
||||
{ label: 'Instrument', value: de ? 'Stammkapital + Wandeldarlehen' : 'Equity + Convertible', icon: Briefcase },
|
||||
{ label: 'Investment', value: '1.000.000 EUR', icon: Briefcase },
|
||||
{ label: 'Post-Money', value: '5.000.000 EUR', icon: TrendingUp },
|
||||
{ label: de ? 'Investoranteil' : 'Investor Share', value: '20%', icon: Users },
|
||||
].map((item, idx) => {
|
||||
const Icon = item.icon
|
||||
return (
|
||||
@@ -100,66 +100,6 @@ export default function CapTableSlide({ lang }: CapTableSlideProps) {
|
||||
</FadeInView>
|
||||
</div>
|
||||
|
||||
{/* Gründergehälter + Gewinnverwendung */}
|
||||
<div className="grid md:grid-cols-2 gap-6 mb-6">
|
||||
<FadeInView delay={0.2}>
|
||||
<GlassCard hover={false} className="p-5">
|
||||
<h3 className="text-sm font-bold text-emerald-400 uppercase tracking-wider mb-3">
|
||||
{de ? 'Gründergehälter' : 'Founder Salaries'}
|
||||
</h3>
|
||||
<p className="text-xs text-white/50 mb-3">
|
||||
{de
|
||||
? 'Bewusst unter Markt — zeigt Investoren Skin in the Game und Kapitaldisziplin.'
|
||||
: 'Deliberately below market — shows investors skin in the game and capital discipline.'}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
{ period: '2026 (Aug–Dez)', salary: de ? '0 EUR (unbezahlt)' : 'EUR 0 (unpaid)', note: de ? 'Gründungsphase' : 'Founding phase' },
|
||||
{ period: '2027', salary: '7.000 EUR/Mo', note: de ? 'Unter Marktniveau' : 'Below market' },
|
||||
{ period: '2028', salary: '~8.000 EUR/Mo', note: de ? 'Product-Market Fit' : 'Product-market fit' },
|
||||
{ period: '2029+', salary: '~9.100 EUR/Mo', note: de ? 'Break-Even erreicht' : 'Break-even reached' },
|
||||
].map((item, idx) => (
|
||||
<div key={idx} className="flex items-center justify-between text-xs">
|
||||
<span className="text-white/40 min-w-[100px]">{item.period}</span>
|
||||
<span className="text-white/70 font-mono">{item.salary}</span>
|
||||
<span className="text-white/30 text-[10px]">{item.note}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</GlassCard>
|
||||
</FadeInView>
|
||||
|
||||
<FadeInView delay={0.25}>
|
||||
<GlassCard hover={false} className="p-5">
|
||||
<h3 className="text-sm font-bold text-purple-400 uppercase tracking-wider mb-3">
|
||||
{de ? 'Gewinnverwendung' : 'Use of Profits'}
|
||||
</h3>
|
||||
<p className="text-xs text-white/50 mb-3">
|
||||
{de
|
||||
? '100% Reinvestition in Wachstum — Standard bei schnell wachsenden SaaS-Startups.'
|
||||
: '100% reinvestment in growth — standard for fast-growing SaaS startups.'}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{(de ? [
|
||||
'Keine Gewinnausschüttung bis mindestens Series A',
|
||||
'Jeder Euro in Wachstum bringt 3-5x Return in 2-3 Jahren',
|
||||
'Investition in: Engineering, Vertrieb, EU-Expansion',
|
||||
'Gründer partizipieren über Equity-Wertsteigerung, nicht Gehalt',
|
||||
] : [
|
||||
'No profit distribution until at least Series A',
|
||||
'Every euro in growth returns 3-5x in 2-3 years',
|
||||
'Investment in: engineering, sales, EU expansion',
|
||||
'Founders participate through equity appreciation, not salary',
|
||||
]).map((item, idx) => (
|
||||
<p key={idx} className="text-xs text-white/60 pl-3 relative">
|
||||
<span className="absolute left-0 top-1 w-1.5 h-1.5 rounded-full bg-purple-400/60" />
|
||||
{item}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</GlassCard>
|
||||
</FadeInView>
|
||||
</div>
|
||||
|
||||
{/* ESOP + INVEST + Series A */}
|
||||
<div className="grid md:grid-cols-3 gap-4 mb-6">
|
||||
@@ -183,12 +123,12 @@ export default function CapTableSlide({ lang }: CapTableSlideProps) {
|
||||
</h3>
|
||||
<p className="text-xs text-white/60 leading-relaxed mb-2">
|
||||
{de
|
||||
? 'Das BAFA-Programm "INVEST — Zuschuss für Wagniskapital" erstattet Business Angels 20% ihres Investments als nicht rückzahlbaren Zuschuss.'
|
||||
: 'The BAFA program "INVEST — Venture Capital Grant" reimburses business angels 20% of their investment as a non-repayable grant.'}
|
||||
? 'Das BAFA-Programm "INVEST — Zuschuss für Wagniskapital" erstattet Business Angels bis zu 15% ihres Investments als steuerfreien Zuschuss plus 25% Exit-Zuschuss.'
|
||||
: 'The BAFA program "INVEST — Venture Capital Grant" reimburses business angels up to 15% of their investment as a tax-free grant plus 25% exit grant.'}
|
||||
</p>
|
||||
<div className="space-y-1 text-xs">
|
||||
<div className="flex justify-between"><span className="text-white/40">{de ? 'Zuschuss' : 'Grant'}</span><span className="text-emerald-300 font-bold">20%</span></div>
|
||||
<div className="flex justify-between"><span className="text-white/40">{de ? 'Bei 975k Investment' : 'On EUR 975k'}</span><span className="text-emerald-300 font-bold">195.000 EUR</span></div>
|
||||
<div className="flex justify-between"><span className="text-white/40">{de ? 'Erwerbszuschuss' : 'Acquisition grant'}</span><span className="text-emerald-300 font-bold">15%</span></div>
|
||||
<div className="flex justify-between"><span className="text-white/40">{de ? 'Exit-Zuschuss' : 'Exit grant'}</span><span className="text-emerald-300 font-bold">25%</span></div>
|
||||
<div className="flex justify-between"><span className="text-white/40">{de ? 'Max. pro Investor/Jahr' : 'Max. per investor/yr'}</span><span className="text-white/60">500.000 EUR</span></div>
|
||||
<div className="flex justify-between"><span className="text-white/40">{de ? 'Haltefrist' : 'Holding period'}</span><span className="text-white/60">{de ? '3 Jahre' : '3 years'}</span></div>
|
||||
</div>
|
||||
@@ -198,7 +138,7 @@ export default function CapTableSlide({ lang }: CapTableSlideProps) {
|
||||
<FadeInView delay={0.4}>
|
||||
<GlassCard hover={false} className="p-4">
|
||||
<h3 className="text-xs font-bold text-blue-400 uppercase tracking-wider mb-2">
|
||||
{de ? 'Series A Ausblick (Q1/Q2 2028)' : 'Series A Outlook (Q1/Q2 2028)'}
|
||||
{de ? 'Series A Ausblick (Optional)' : 'Series A Outlook (Optional)'}
|
||||
</h3>
|
||||
<div className="space-y-1.5 text-xs">
|
||||
<div className="flex justify-between"><span className="text-white/40">{de ? 'Bewertung' : 'Valuation'}</span><span className="text-white/70 font-mono">15-25 Mio. EUR</span></div>
|
||||
@@ -212,6 +152,7 @@ export default function CapTableSlide({ lang }: CapTableSlideProps) {
|
||||
</GlassCard>
|
||||
</FadeInView>
|
||||
</div>
|
||||
<ProjectionFooter lang={lang} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ const EXTENDED_COMPETITORS: ExtendedCompetitor[] = [
|
||||
revenue: '$220M ARR',
|
||||
revenueNum: 220_000_000,
|
||||
customers: 12000,
|
||||
customerCountries: '58 Laender',
|
||||
customerCountries: '58 Länder',
|
||||
fundingTotal: '$504M',
|
||||
fundingRound: 'Series D ($150M, $4.15B val.)',
|
||||
investors: ['Sequoia Capital', 'Wellington Mgmt', 'Craft Ventures', 'CrowdStrike', 'Goldman Sachs', 'Y Combinator'],
|
||||
@@ -75,7 +75,7 @@ const EXTENDED_COMPETITORS: ExtendedCompetitor[] = [
|
||||
revenue: '$100M ARR',
|
||||
revenueNum: 100_000_000,
|
||||
customers: 8000,
|
||||
customerCountries: '80+ Laender',
|
||||
customerCountries: '80+ Länder',
|
||||
fundingTotal: '$328M',
|
||||
fundingRound: 'Series C ($200M, $2B val.)',
|
||||
investors: ['ICONIQ Growth', 'GGV Capital', 'Salesforce Ventures', 'SentinelOne'],
|
||||
@@ -96,7 +96,7 @@ const EXTENDED_COMPETITORS: ExtendedCompetitor[] = [
|
||||
revenue: '$38M ARR',
|
||||
revenueNum: 38_000_000,
|
||||
customers: 3000,
|
||||
customerCountries: '75+ Laender',
|
||||
customerCountries: '75+ Länder',
|
||||
fundingTotal: '$32M',
|
||||
fundingRound: 'Series B ($20M, 2024)',
|
||||
investors: ['Accel', 'Elevation Capital', 'Blume Ventures'],
|
||||
@@ -138,7 +138,7 @@ const EXTENDED_COMPETITORS: ExtendedCompetitor[] = [
|
||||
revenue: '~€52M',
|
||||
revenueNum: 52_000_000,
|
||||
customers: 4000,
|
||||
customerCountries: '50+ Laender',
|
||||
customerCountries: '50+ Länder',
|
||||
fundingTotal: '€80M',
|
||||
fundingRound: 'Series B (€61M, €341M val.)',
|
||||
investors: ['Morgan Stanley Expansion', 'One Peak Partners'],
|
||||
@@ -187,64 +187,68 @@ interface ComparisonFeature {
|
||||
heydata: FeatureStatus
|
||||
isDiff: boolean
|
||||
isUSP: boolean
|
||||
group?: string
|
||||
}
|
||||
|
||||
const ALL_FEATURES: ComparisonFeature[] = [
|
||||
// Top 5 Differentiators (isDiff=true) — no other vendor has ANY of these
|
||||
{ de: 'Self-Hosted / On-Premise', en: 'Self-Hosted / On-Premise', bp: true, vanta: false, drata: false, sprinto: false, proliance: false, dataguard: false, heydata: false, isDiff: true, isUSP: true },
|
||||
{ de: 'Code-Security & DevSecOps (6 Tools)', en: 'Code Security & DevSecOps (6 Tools)', bp: true, vanta: false, drata: false, sprinto: false, proliance: false, dataguard: false, heydata: false, isDiff: true, isUSP: true },
|
||||
{ de: '110 Gesetze & Regularien, 25.000+ Sicherheitskontrollen', en: '110 Laws & Regulations, 25,000+ Security Controls', bp: true, vanta: false, drata: false, sprinto: false, proliance: false, dataguard: false, heydata: false, isDiff: true, isUSP: true },
|
||||
{ de: 'Hardware-Moat (Mac Mini/Studio)', en: 'Hardware Moat (Mac Mini/Studio)', bp: true, vanta: false, drata: false, sprinto: false, proliance: false, dataguard: false, heydata: false, isDiff: true, isUSP: true },
|
||||
{ de: 'PII-Redaction LLM Gateway', en: 'PII Redaction LLM Gateway', bp: true, vanta: false, drata: false, sprinto: false, proliance: false, dataguard: false, heydata: false, isDiff: true, isUSP: true },
|
||||
// More USPs
|
||||
{ de: 'IPFS Dokumenten-Archivierung', en: 'IPFS Document Archiving', bp: true, vanta: false, drata: false, sprinto: false, proliance: false, dataguard: false, heydata: false, isDiff: false, isUSP: true },
|
||||
{ de: 'SBOM-Generator (CycloneDX/SPDX)', en: 'SBOM Generator (CycloneDX/SPDX)', bp: true, vanta: false, drata: false, sprinto: false, proliance: false, dataguard: false, heydata: false, isDiff: false, isUSP: true },
|
||||
{ de: 'Multi-Framework Consent SDK', en: 'Multi-Framework Consent SDK', bp: true, vanta: false, drata: false, sprinto: false, proliance: false, dataguard: false, heydata: false, isDiff: false, isUSP: true },
|
||||
{ de: 'RAG mit 25.000+ Sicherheitskontrollen', en: 'RAG with 25,000+ Security Controls', bp: true, vanta: false, drata: false, sprinto: false, proliance: false, dataguard: false, heydata: false, isDiff: false, isUSP: true },
|
||||
// Pentesting & Code-Security (kein Compliance-Wettbewerber hat dies)
|
||||
{ de: 'SAST (Static Application Security Testing)', en: 'SAST (Static Application Security Testing)', bp: true, vanta: false, drata: false, sprinto: false, proliance: false, dataguard: false, heydata: false, isDiff: false, isUSP: true },
|
||||
{ de: 'DAST (Dynamic Application Security Testing)', en: 'DAST (Dynamic Application Security Testing)', bp: true, vanta: false, drata: false, sprinto: false, proliance: false, dataguard: false, heydata: false, isDiff: false, isUSP: true },
|
||||
{ de: 'LLM-Auto-Fix (automatische Code-Korrekturen)', en: 'LLM Auto-Fix (Automatic Code Corrections)', bp: true, vanta: false, drata: false, sprinto: false, proliance: false, dataguard: false, heydata: false, isDiff: false, isUSP: true },
|
||||
{ de: 'Container-Security Scanning (Trivy)', en: 'Container Security Scanning (Trivy)', bp: true, vanta: false, drata: false, sprinto: false, proliance: false, dataguard: false, heydata: false, isDiff: false, isUSP: true },
|
||||
{ de: 'Secret Detection (Gitleaks)', en: 'Secret Detection (Gitleaks)', bp: true, vanta: false, drata: false, sprinto: false, proliance: false, dataguard: false, heydata: false, isDiff: false, isUSP: true },
|
||||
// Compliance Features (shared)
|
||||
{ de: 'DSGVO / GDPR', en: 'GDPR', bp: true, vanta: 'partial', drata: 'partial', sprinto: 'partial', proliance: true, dataguard: true, heydata: true, isDiff: false, isUSP: false },
|
||||
{ de: 'AI Act', en: 'AI Act', bp: true, vanta: false, drata: false, sprinto: false, proliance: false, dataguard: false, heydata: false, isDiff: false, isUSP: false },
|
||||
{ de: 'Cyber Resilience Act (CRA)', en: 'Cyber Resilience Act (CRA)', bp: true, vanta: false, drata: false, sprinto: false, proliance: false, dataguard: false, heydata: false, isDiff: false, isUSP: false },
|
||||
{ de: 'NIS2-Richtlinie', en: 'NIS2 Directive', bp: true, vanta: false, drata: 'partial', sprinto: false, proliance: false, dataguard: 'partial', heydata: false, isDiff: false, isUSP: false },
|
||||
{ de: 'SOC 2', en: 'SOC 2', bp: 'partial', vanta: true, drata: true, sprinto: true, proliance: false, dataguard: true, heydata: false, isDiff: false, isUSP: false },
|
||||
{ de: 'ISO 27001', en: 'ISO 27001', bp: true, vanta: true, drata: true, sprinto: true, proliance: false, dataguard: true, heydata: false, isDiff: false, isUSP: false },
|
||||
{ de: 'HIPAA', en: 'HIPAA', bp: false, vanta: true, drata: true, sprinto: true, proliance: false, dataguard: false, heydata: false, isDiff: false, isUSP: false },
|
||||
{ de: 'TISAX', en: 'TISAX', bp: true, vanta: false, drata: false, sprinto: false, proliance: false, dataguard: true, heydata: false, isDiff: false, isUSP: false },
|
||||
{ de: 'HinSchG (Whistleblower)', en: 'HinSchG (Whistleblower)', bp: true, vanta: false, drata: false, sprinto: false, proliance: 'partial', dataguard: false, heydata: false, isDiff: false, isUSP: false },
|
||||
// Functional Features
|
||||
{ de: 'VVT (Art. 30 DSGVO)', en: 'Records of Processing (Art. 30)', bp: true, vanta: false, drata: false, sprinto: false, proliance: true, dataguard: true, heydata: true, isDiff: false, isUSP: false },
|
||||
{ de: 'TOM-Dokumentation', en: 'TOM Documentation', bp: true, vanta: false, drata: false, sprinto: false, proliance: true, dataguard: true, heydata: 'partial', isDiff: false, isUSP: false },
|
||||
{ de: 'DSFA (Art. 35 DSGVO)', en: 'DPIA (Art. 35 GDPR)', bp: true, vanta: false, drata: false, sprinto: false, proliance: true, dataguard: true, heydata: false, isDiff: false, isUSP: false },
|
||||
{ de: 'Loeschkonzept / Loeschfristen', en: 'Deletion Concept / Retention', bp: true, vanta: false, drata: false, sprinto: false, proliance: 'partial', dataguard: 'partial', heydata: false, isDiff: false, isUSP: false },
|
||||
{ de: 'Auftragsverarbeiter-Mgmt', en: 'Vendor/Processor Management', bp: true, vanta: true, drata: true, sprinto: 'partial', proliance: true, dataguard: true, heydata: 'partial', isDiff: false, isUSP: false },
|
||||
{ de: 'Consent Management', en: 'Consent Management', bp: true, vanta: false, drata: false, sprinto: false, proliance: 'partial', dataguard: false, heydata: 'partial', isDiff: false, isUSP: false },
|
||||
{ de: 'Betroffenenrechte (DSR)', en: 'Data Subject Requests', bp: true, vanta: false, drata: false, sprinto: false, proliance: true, dataguard: true, heydata: 'partial', isDiff: false, isUSP: false },
|
||||
{ de: 'Risikobewertung', en: 'Risk Assessment', bp: true, vanta: true, drata: true, sprinto: true, proliance: 'partial', dataguard: true, heydata: false, isDiff: false, isUSP: false },
|
||||
{ de: 'Audit-Management', en: 'Audit Management', bp: true, vanta: true, drata: true, sprinto: true, proliance: false, dataguard: true, heydata: false, isDiff: false, isUSP: false },
|
||||
{ de: 'Schulungs-Management', en: 'Training Management', bp: true, vanta: 'partial', drata: 'partial', sprinto: 'partial', proliance: false, dataguard: 'partial', heydata: false, isDiff: false, isUSP: false },
|
||||
{ de: 'Policy-Generator', en: 'Policy Generator', bp: true, vanta: true, drata: true, sprinto: 'partial', proliance: true, dataguard: true, heydata: 'partial', isDiff: false, isUSP: false },
|
||||
{ de: 'Incident Response', en: 'Incident Response', bp: true, vanta: true, drata: true, sprinto: true, proliance: false, dataguard: 'partial', heydata: false, isDiff: false, isUSP: false },
|
||||
// Technical Features
|
||||
{ de: 'KI-gestuetzte Analyse', en: 'AI-Powered Analysis', bp: true, vanta: true, drata: true, sprinto: 'partial', proliance: false, dataguard: true, heydata: false, isDiff: false, isUSP: false },
|
||||
{ de: 'Automatische Evidence-Sammlung', en: 'Automatic Evidence Collection', bp: true, vanta: true, drata: true, sprinto: true, proliance: false, dataguard: 'partial', heydata: false, isDiff: false, isUSP: false },
|
||||
{ de: 'Continuous Monitoring', en: 'Continuous Monitoring', bp: true, vanta: true, drata: true, sprinto: true, proliance: false, dataguard: true, heydata: false, isDiff: false, isUSP: false },
|
||||
{ de: 'Integrations (Slack, Jira, etc.)', en: 'Integrations (Slack, Jira, etc.)', bp: 'partial', vanta: true, drata: true, sprinto: true, proliance: false, dataguard: 'partial', heydata: false, isDiff: false, isUSP: false },
|
||||
{ de: 'API / SDK', en: 'API / SDK', bp: true, vanta: true, drata: true, sprinto: 'partial', proliance: false, dataguard: 'partial', heydata: false, isDiff: false, isUSP: false },
|
||||
{ de: 'Datensouveraenitaet (EU)', en: 'Data Sovereignty (EU)', bp: true, vanta: false, drata: false, sprinto: false, proliance: true, dataguard: true, heydata: true, isDiff: false, isUSP: false },
|
||||
{ de: 'Mehrmandantenfaehig', en: 'Multi-Tenancy', bp: true, vanta: true, drata: true, sprinto: true, proliance: 'partial', dataguard: true, heydata: false, isDiff: false, isUSP: false },
|
||||
{ de: 'Data Mapping / Datenfluss', en: 'Data Mapping / Data Flow', bp: true, vanta: 'partial', drata: 'partial', sprinto: false, proliance: false, dataguard: 'partial', heydata: false, isDiff: false, isUSP: false },
|
||||
{ de: 'Cookie-Banner Generator', en: 'Cookie Banner Generator', bp: true, vanta: false, drata: false, sprinto: false, proliance: 'partial', dataguard: false, heydata: 'partial', isDiff: false, isUSP: false },
|
||||
{ de: 'Dokument-Generator (61 Vorlagen)', en: 'Document Generator (61 Templates)', bp: true, vanta: 'partial', drata: 'partial', sprinto: false, proliance: 'partial', dataguard: 'partial', heydata: false, isDiff: false, isUSP: false },
|
||||
{ de: 'Whistleblower-Portal', en: 'Whistleblower Portal', bp: true, vanta: false, drata: false, sprinto: false, proliance: false, dataguard: false, heydata: false, isDiff: false, isUSP: false },
|
||||
{ de: 'Maschinenbau-Branchenfokus', en: 'Manufacturing Industry Focus', bp: true, vanta: false, drata: false, sprinto: false, proliance: false, dataguard: false, heydata: false, isDiff: false, isUSP: false },
|
||||
{ de: 'Firmware & Embedded-Security', en: 'Firmware & Embedded Security', bp: true, vanta: false, drata: false, sprinto: false, proliance: false, dataguard: false, heydata: false, isDiff: false, isUSP: false },
|
||||
{ de: 'Autonomer KI-Support-Agent', en: 'Autonomous AI Support Agent', bp: true, vanta: false, drata: false, sprinto: false, proliance: false, dataguard: false, heydata: false, isDiff: false, isUSP: false },
|
||||
// ── Code Security & DevSecOps ──
|
||||
{ de: 'Code-Security & DevSecOps (6 Tools)', en: 'Code Security & DevSecOps (6 Tools)', bp: true, vanta: false, drata: false, sprinto: false, proliance: false, dataguard: false, heydata: false, isDiff: true, isUSP: true, group: 'code-security' },
|
||||
{ de: 'SAST (Static Application Security Testing)', en: 'SAST (Static Application Security Testing)', bp: true, vanta: false, drata: false, sprinto: false, proliance: false, dataguard: false, heydata: false, isDiff: true, isUSP: true, group: 'code-security' },
|
||||
{ de: 'DAST (Dynamic Application Security Testing)', en: 'DAST (Dynamic Application Security Testing)', bp: true, vanta: false, drata: false, sprinto: false, proliance: false, dataguard: false, heydata: false, isDiff: true, isUSP: true, group: 'code-security' },
|
||||
{ de: 'SBOM-Generator (CycloneDX/SPDX)', en: 'SBOM Generator (CycloneDX/SPDX)', bp: true, vanta: false, drata: false, sprinto: false, proliance: false, dataguard: false, heydata: false, isDiff: true, isUSP: true, group: 'code-security' },
|
||||
{ de: 'Container-Security Scanning (Trivy)', en: 'Container Security Scanning (Trivy)', bp: true, vanta: false, drata: false, sprinto: false, proliance: false, dataguard: false, heydata: false, isDiff: true, isUSP: true, group: 'code-security' },
|
||||
{ de: 'Secret Detection (Gitleaks)', en: 'Secret Detection (Gitleaks)', bp: true, vanta: false, drata: false, sprinto: false, proliance: false, dataguard: false, heydata: false, isDiff: true, isUSP: true, group: 'code-security' },
|
||||
{ de: 'LLM-Auto-Fix (automatische Code-Korrekturen)', en: 'LLM Auto-Fix (Automatic Code Corrections)', bp: true, vanta: false, drata: false, sprinto: false, proliance: false, dataguard: false, heydata: false, isDiff: true, isUSP: true, group: 'code-security' },
|
||||
{ de: 'Firmware & Embedded-Security', en: 'Firmware & Embedded Security', bp: true, vanta: false, drata: false, sprinto: false, proliance: false, dataguard: false, heydata: false, isDiff: true, isUSP: true, group: 'code-security' },
|
||||
|
||||
// ── KI & Daten ──
|
||||
{ de: 'PII-Redaction LLM Gateway', en: 'PII Redaction LLM Gateway', bp: true, vanta: false, drata: false, sprinto: false, proliance: false, dataguard: false, heydata: false, isDiff: true, isUSP: true, group: 'ai-data' },
|
||||
{ de: 'RAG mit 25.000+ Sicherheitskontrollen', en: 'RAG with 25,000+ Security Controls', bp: true, vanta: false, drata: false, sprinto: false, proliance: false, dataguard: false, heydata: false, isDiff: true, isUSP: true, group: 'ai-data' },
|
||||
{ de: 'Autonomer KI-Support-Agent', en: 'Autonomous AI Support Agent', bp: true, vanta: false, drata: false, sprinto: false, proliance: false, dataguard: false, heydata: false, isDiff: true, isUSP: true, group: 'ai-data' },
|
||||
{ de: 'KI-gestützte Analyse', en: 'AI-Powered Analysis', bp: true, vanta: true, drata: true, sprinto: 'partial', proliance: false, dataguard: true, heydata: false, isDiff: false, isUSP: false, group: 'ai-data' },
|
||||
|
||||
// ── Regulatorische Frameworks ──
|
||||
{ de: 'DSGVO / GDPR', en: 'GDPR', bp: true, vanta: 'partial', drata: 'partial', sprinto: 'partial', proliance: true, dataguard: true, heydata: true, isDiff: false, isUSP: false, group: 'frameworks' },
|
||||
{ de: 'AI Act', en: 'AI Act', bp: true, vanta: false, drata: false, sprinto: false, proliance: false, dataguard: false, heydata: false, isDiff: true, isUSP: true, group: 'frameworks' },
|
||||
{ de: 'Cyber Resilience Act (CRA)', en: 'Cyber Resilience Act (CRA)', bp: true, vanta: false, drata: false, sprinto: false, proliance: false, dataguard: false, heydata: false, isDiff: true, isUSP: true, group: 'frameworks' },
|
||||
{ de: 'NIS2-Richtlinie', en: 'NIS2 Directive', bp: true, vanta: false, drata: 'partial', sprinto: false, proliance: false, dataguard: 'partial', heydata: false, isDiff: false, isUSP: false, group: 'frameworks' },
|
||||
{ de: 'SOC 2', en: 'SOC 2', bp: 'partial', vanta: true, drata: true, sprinto: true, proliance: false, dataguard: true, heydata: false, isDiff: false, isUSP: false, group: 'frameworks' },
|
||||
{ de: 'ISO 27001', en: 'ISO 27001', bp: true, vanta: true, drata: true, sprinto: true, proliance: false, dataguard: true, heydata: false, isDiff: false, isUSP: false, group: 'frameworks' },
|
||||
{ de: 'HIPAA', en: 'HIPAA', bp: false, vanta: true, drata: true, sprinto: true, proliance: false, dataguard: false, heydata: false, isDiff: false, isUSP: false, group: 'frameworks' },
|
||||
{ de: 'TISAX', en: 'TISAX', bp: true, vanta: false, drata: false, sprinto: false, proliance: false, dataguard: true, heydata: false, isDiff: false, isUSP: false, group: 'frameworks' },
|
||||
{ de: 'HinSchG (Whistleblower)', en: 'HinSchG (Whistleblower)', bp: true, vanta: false, drata: false, sprinto: false, proliance: 'partial', dataguard: false, heydata: false, isDiff: false, isUSP: false, group: 'frameworks' },
|
||||
|
||||
// ── Compliance-Dokumentation ──
|
||||
{ de: 'VVT (Art. 30 DSGVO)', en: 'Records of Processing (Art. 30)', bp: true, vanta: false, drata: false, sprinto: false, proliance: true, dataguard: true, heydata: true, isDiff: false, isUSP: false, group: 'documentation' },
|
||||
{ de: 'TOM-Dokumentation', en: 'TOM Documentation', bp: true, vanta: false, drata: false, sprinto: false, proliance: true, dataguard: true, heydata: 'partial', isDiff: false, isUSP: false, group: 'documentation' },
|
||||
{ de: 'DSFA (Art. 35 DSGVO)', en: 'DPIA (Art. 35 GDPR)', bp: true, vanta: false, drata: false, sprinto: false, proliance: true, dataguard: true, heydata: false, isDiff: false, isUSP: false, group: 'documentation' },
|
||||
{ de: 'Löschkonzept / Löschfristen', en: 'Deletion Concept / Retention', bp: true, vanta: false, drata: false, sprinto: false, proliance: 'partial', dataguard: 'partial', heydata: false, isDiff: false, isUSP: false, group: 'documentation' },
|
||||
{ de: 'Policy-Generator', en: 'Policy Generator', bp: true, vanta: true, drata: true, sprinto: 'partial', proliance: true, dataguard: true, heydata: 'partial', isDiff: false, isUSP: false, group: 'documentation' },
|
||||
{ de: 'Dokument-Generator (61 Vorlagen)', en: 'Document Generator (61 Templates)', bp: true, vanta: 'partial', drata: 'partial', sprinto: false, proliance: 'partial', dataguard: 'partial', heydata: false, isDiff: false, isUSP: false, group: 'documentation' },
|
||||
|
||||
// ── Operative Compliance ──
|
||||
{ de: 'Audit-Management', en: 'Audit Management', bp: true, vanta: true, drata: true, sprinto: true, proliance: false, dataguard: true, heydata: false, isDiff: false, isUSP: false, group: 'operations' },
|
||||
{ de: 'Risikobewertung', en: 'Risk Assessment', bp: true, vanta: true, drata: true, sprinto: true, proliance: 'partial', dataguard: true, heydata: false, isDiff: false, isUSP: false, group: 'operations' },
|
||||
{ de: 'Incident Response', en: 'Incident Response', bp: true, vanta: true, drata: true, sprinto: true, proliance: false, dataguard: 'partial', heydata: false, isDiff: false, isUSP: false, group: 'operations' },
|
||||
{ de: 'Consent Management', en: 'Consent Management', bp: true, vanta: false, drata: false, sprinto: false, proliance: 'partial', dataguard: false, heydata: 'partial', isDiff: false, isUSP: false, group: 'operations' },
|
||||
{ de: 'Betroffenenrechte (DSR)', en: 'Data Subject Requests', bp: true, vanta: false, drata: false, sprinto: false, proliance: true, dataguard: true, heydata: 'partial', isDiff: false, isUSP: false, group: 'operations' },
|
||||
{ de: 'Auftragsverarbeiter-Mgmt', en: 'Vendor/Processor Management', bp: true, vanta: true, drata: true, sprinto: 'partial', proliance: true, dataguard: true, heydata: 'partial', isDiff: false, isUSP: false, group: 'operations' },
|
||||
{ de: 'Schulungs-Management', en: 'Training Management', bp: true, vanta: 'partial', drata: 'partial', sprinto: 'partial', proliance: false, dataguard: 'partial', heydata: false, isDiff: false, isUSP: false, group: 'operations' },
|
||||
{ de: 'Whistleblower-Portal', en: 'Whistleblower Portal', bp: true, vanta: false, drata: false, sprinto: false, proliance: false, dataguard: false, heydata: false, isDiff: true, isUSP: true, group: 'operations' },
|
||||
|
||||
// ── Technische Plattform ──
|
||||
{ de: 'Continuous Monitoring', en: 'Continuous Monitoring', bp: true, vanta: true, drata: true, sprinto: true, proliance: false, dataguard: true, heydata: false, isDiff: false, isUSP: false, group: 'platform' },
|
||||
{ de: 'Automatische Evidence-Sammlung', en: 'Automatic Evidence Collection', bp: true, vanta: true, drata: true, sprinto: true, proliance: false, dataguard: 'partial', heydata: false, isDiff: false, isUSP: false, group: 'platform' },
|
||||
{ de: 'API / SDK', en: 'API / SDK', bp: true, vanta: true, drata: true, sprinto: 'partial', proliance: false, dataguard: 'partial', heydata: false, isDiff: false, isUSP: false, group: 'platform' },
|
||||
{ de: 'Integrations (Slack, Jira, etc.)', en: 'Integrations (Slack, Jira, etc.)', bp: 'partial', vanta: true, drata: true, sprinto: true, proliance: false, dataguard: 'partial', heydata: false, isDiff: false, isUSP: false, group: 'platform' },
|
||||
{ de: 'Datensouveraenitaet (EU)', en: 'Data Sovereignty (EU)', bp: true, vanta: false, drata: false, sprinto: false, proliance: true, dataguard: true, heydata: true, isDiff: false, isUSP: false, group: 'platform' },
|
||||
{ de: 'Mehrmandantenfähig', en: 'Multi-Tenancy', bp: true, vanta: true, drata: true, sprinto: true, proliance: 'partial', dataguard: true, heydata: false, isDiff: false, isUSP: false, group: 'platform' },
|
||||
{ de: 'Data Mapping / Datenfluss', en: 'Data Mapping / Data Flow', bp: true, vanta: 'partial', drata: 'partial', sprinto: false, proliance: false, dataguard: 'partial', heydata: false, isDiff: false, isUSP: false, group: 'platform' },
|
||||
{ de: 'Cookie-Banner Generator', en: 'Cookie Banner Generator', bp: true, vanta: false, drata: false, sprinto: false, proliance: 'partial', dataguard: false, heydata: 'partial', isDiff: false, isUSP: false, group: 'platform' },
|
||||
{ de: 'IPFS/Blockchain (optional)', en: 'IPFS/Blockchain (optional)', bp: true, vanta: false, drata: false, sprinto: false, proliance: false, dataguard: false, heydata: false, isDiff: true, isUSP: true, group: 'platform' },
|
||||
|
||||
// ── Branche & Spezial ──
|
||||
{ de: 'Maschinenbau-Branchenfokus', en: 'Manufacturing Industry Focus', bp: true, vanta: false, drata: false, sprinto: false, proliance: false, dataguard: false, heydata: false, isDiff: true, isUSP: true, group: 'industry' },
|
||||
]
|
||||
|
||||
// ─── DACH Landscape Note ───────────────────────────────────────────────────────
|
||||
@@ -277,13 +281,12 @@ const PRICING_COMPARISON: CompetitorPricing[] = [
|
||||
{
|
||||
name: 'ComplAI',
|
||||
flag: '🇩🇪',
|
||||
model: 'Cloud (BSI DE / OVH FR)',
|
||||
model: 'Cloud (BSI DE)',
|
||||
publicPricing: true,
|
||||
tiers: [
|
||||
{ name: { de: 'Startup/<10', en: 'Startup/<10' }, price: 'ab €300/mo', annual: 'ab €3.600/yr', notes: { de: '14-Tage-Test, Kreditkarte', en: '14-day trial, credit card' } },
|
||||
{ name: { de: '10-50 MA', en: '10-50 emp.' }, price: 'ab €1.250/mo', annual: 'ab €15.000/yr', notes: { de: 'Cloud, modular, 110 Regularien', en: 'Cloud, modular, 110 regulations' } },
|
||||
{ name: { de: '50-250 MA', en: '50-250 emp.' }, price: 'ab €2.500/mo', annual: 'ab €30.000/yr', notes: { de: 'Cloud, alle Module, Priority', en: 'Cloud, all modules, priority' } },
|
||||
{ name: { de: '250+ MA', en: '250+ emp.' }, price: 'ab €3.500/mo', annual: 'ab €40.000/yr', notes: { de: 'Cloud, Enterprise, Dedicated', en: 'Cloud, enterprise, dedicated' } },
|
||||
{ name: { de: 'Starter (<10 MA)', en: 'Starter (<10 emp.)' }, price: '€300/mo', annual: '€3.600/yr', notes: { de: '380+ Regularien, modular', en: '380+ regulations, modular' } },
|
||||
{ name: { de: 'Professional (10-250)', en: 'Professional (10-250)' }, price: '€1.250–3.333/mo', annual: '€15.000–40.000/yr', notes: { de: 'Alle Module, Priority Support', en: 'All modules, priority support' } },
|
||||
{ name: { de: 'Enterprise (250+)', en: 'Enterprise (250+)' }, price: 'ab €4.167/mo', annual: 'ab €50.000/yr', notes: { de: 'Dedicated, Custom, SLA', en: 'Dedicated, custom, SLA' } },
|
||||
],
|
||||
setupFee: '€0',
|
||||
isBP: true,
|
||||
@@ -405,15 +408,7 @@ interface AppSecFeature {
|
||||
}
|
||||
|
||||
const APPSEC_FEATURES: AppSecFeature[] = [
|
||||
// ComplAI USPs — kein AppSec-Anbieter hat dies
|
||||
{ de: 'DSGVO / GDPR Compliance', en: 'GDPR Compliance', bp: true, snyk: false, veracode: false, checkmarx: false, sonar: false, semgrep: false, pentera: false, invicti: false, intruder: false, isUSP: true },
|
||||
{ de: 'AI Act Compliance', en: 'AI Act Compliance', bp: true, snyk: false, veracode: false, checkmarx: false, sonar: false, semgrep: false, pentera: false, invicti: false, intruder: false, isUSP: true },
|
||||
{ de: 'CRA & NIS2 Compliance', en: 'CRA & NIS2 Compliance', bp: true, snyk: false, veracode: false, checkmarx: false, sonar: false, semgrep: false, pentera: false, invicti: false, intruder: false, isUSP: true },
|
||||
{ de: '57 Compliance-Module (SDK)', en: '57 Compliance Modules (SDK)', bp: true, snyk: false, veracode: false, checkmarx: false, sonar: false, semgrep: false, pentera: false, invicti: false, intruder: false, isUSP: true },
|
||||
{ de: 'Self-Hosted KI (On-Premise)', en: 'Self-Hosted AI (On-Premise)', bp: true, snyk: false, veracode: false, checkmarx: false, sonar: false, semgrep: false, pentera: false, invicti: false, intruder: false, isUSP: true },
|
||||
{ de: 'PII-Redaction LLM Gateway', en: 'PII Redaction LLM Gateway', bp: true, snyk: false, veracode: false, checkmarx: false, sonar: false, semgrep: false, pentera: false, invicti: false, intruder: false, isUSP: true },
|
||||
{ de: 'Firmware & Embedded-Security', en: 'Firmware & Embedded Security', bp: true, snyk: false, veracode: 'partial', checkmarx: false, sonar: false, semgrep: false, pentera: false, invicti: false, intruder: false, isUSP: true },
|
||||
// Shared AppSec Features
|
||||
// Pure AppSec Features only (Compliance USPs removed — belong on Compliance tabs)
|
||||
{ de: 'SAST (Static Analysis)', en: 'SAST (Static Analysis)', bp: true, snyk: true, veracode: true, checkmarx: true, sonar: true, semgrep: true, pentera: false, invicti: false, intruder: false, isUSP: false },
|
||||
{ de: 'DAST (Dynamic Analysis)', en: 'DAST (Dynamic Analysis)', bp: true, snyk: false, veracode: true, checkmarx: true, sonar: false, semgrep: false, pentera: true, invicti: true, intruder: true, isUSP: false },
|
||||
{ de: 'SCA (Software Composition)', en: 'SCA (Software Composition)', bp: true, snyk: true, veracode: true, checkmarx: true, sonar: 'partial', semgrep: 'partial', pentera: false, invicti: false, intruder: false, isUSP: false },
|
||||
@@ -526,7 +521,7 @@ export default function CompetitionSlide({ lang, features, competitors }: Compet
|
||||
{/* Tab Bar */}
|
||||
<FadeInView delay={0.15} className="flex justify-center gap-2 mb-4 flex-wrap">
|
||||
{([
|
||||
{ key: 'overview' as ViewTab, de: 'Ueberblick & Vergleich', en: 'Overview & Comparison' },
|
||||
{ key: 'overview' as ViewTab, de: 'Überblick & Vergleich', en: 'Overview & Comparison' },
|
||||
{ key: 'features' as ViewTab, de: 'Feature-Matrix (Detail)', en: 'Feature Matrix (Detail)' },
|
||||
{ key: 'pricing' as ViewTab, de: 'Pricing-Vergleich', en: 'Pricing Comparison' },
|
||||
{ key: 'appsec' as ViewTab, de: 'Pentesting & AppSec', en: 'Pentesting & AppSec' },
|
||||
@@ -537,7 +532,7 @@ export default function CompetitionSlide({ lang, features, competitors }: Compet
|
||||
className={`px-4 py-1.5 rounded-full text-xs font-medium transition-all ${
|
||||
activeTab === tab.key
|
||||
? 'bg-indigo-500/20 text-indigo-300 border border-indigo-500/30'
|
||||
: 'bg-white/[0.04] text-white/40 border border-white/5 hover:bg-white/[0.08]'
|
||||
: 'bg-white/[0.04] text-white/40 border border-white/5 hover:bg-white/[0.08] animate-[pulse_3s_ease-in-out_infinite]'
|
||||
}`}
|
||||
>
|
||||
{lang === 'de' ? tab.de : tab.en}
|
||||
@@ -630,20 +625,6 @@ export default function CompetitionSlide({ lang, features, competitors }: Compet
|
||||
{activeTab === 'features' && (
|
||||
<FadeInView delay={0.2}>
|
||||
<div className="space-y-2">
|
||||
{/* Top 5 Differences */}
|
||||
<div>
|
||||
<SectionHeader
|
||||
label={lang === 'de' ? 'Top 5 Unterschiede' : 'Top 5 Differences'}
|
||||
count={top5.length}
|
||||
open={openSections.has('top5')}
|
||||
onToggle={() => toggleSection('top5')}
|
||||
accent="text-yellow-400"
|
||||
/>
|
||||
{openSections.has('top5') && (
|
||||
<FeatureTable features={top5} lang={lang} cols={competitorCols} labels={competitorLabels} highlight />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* All Features */}
|
||||
<div>
|
||||
<SectionHeader
|
||||
@@ -660,7 +641,7 @@ export default function CompetitionSlide({ lang, features, competitors }: Compet
|
||||
{/* USPs */}
|
||||
<div>
|
||||
<SectionHeader
|
||||
label={lang === 'de' ? 'USP — nur ComplAI' : 'USP — ComplAI only'}
|
||||
label={lang === 'de' ? 'USP — nur COMPLAI' : 'USP — COMPLAI only'}
|
||||
count={usps.length}
|
||||
open={openSections.has('usp')}
|
||||
onToggle={() => toggleSection('usp')}
|
||||
@@ -705,7 +686,7 @@ export default function CompetitionSlide({ lang, features, competitors }: Compet
|
||||
<th className="py-2 px-1.5 text-white/40 font-medium text-center">Mid</th>
|
||||
<th className="py-2 px-1.5 text-white/40 font-medium text-center">Enterprise</th>
|
||||
<th className="py-2 px-1.5 text-white/40 font-medium text-center">Setup</th>
|
||||
<th className="py-2 px-1.5 text-white/40 font-medium text-center">{lang === 'de' ? 'Oeffentlich' : 'Public'}</th>
|
||||
<th className="py-2 px-1.5 text-white/40 font-medium text-center">{lang === 'de' ? 'Öffentlich' : 'Public'}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -744,39 +725,9 @@ export default function CompetitionSlide({ lang, features, competitors }: Compet
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Key Insights */}
|
||||
<GlassCard className="!p-3 mt-4" hover={false}>
|
||||
<h4 className="text-xs font-semibold text-white/60 mb-2 flex items-center gap-1.5">
|
||||
<Tag className="w-3.5 h-3.5" />
|
||||
{lang === 'de' ? 'Pricing-Einordnung' : 'Pricing Context'}
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 gap-3 text-[11px]">
|
||||
<div className="bg-white/[0.03] rounded-lg p-2">
|
||||
<div className="text-white/50 mb-1">{lang === 'de' ? 'Compliance-Only (DACH)' : 'Compliance Only (DACH)'}</div>
|
||||
<div className="text-white/80 font-medium">€83 – €499/mo</div>
|
||||
<div className="text-white/30 text-[10px]">{lang === 'de' ? 'Proliance, heyData — nur DSGVO, kein Code-Security' : 'Proliance, heyData — GDPR only, no code security'}</div>
|
||||
</div>
|
||||
<div className="bg-white/[0.03] rounded-lg p-2">
|
||||
<div className="text-white/50 mb-1">{lang === 'de' ? 'US-Enterprise (Global)' : 'US Enterprise (Global)'}</div>
|
||||
<div className="text-white/80 font-medium">$500 – $7K+/mo</div>
|
||||
<div className="text-white/30 text-[10px]">{lang === 'de' ? 'Vanta, Drata — SOC 2 Fokus, Setup-Gebuehr, kein Self-Hosted' : 'Vanta, Drata — SOC 2 focus, setup fee, no self-hosted'}</div>
|
||||
</div>
|
||||
<div className="bg-indigo-500/5 border border-indigo-500/10 rounded-lg p-2">
|
||||
<div className="text-indigo-400 mb-1 font-medium">ComplAI</div>
|
||||
<div className="text-white/80 font-medium">€990 – €2.990/mo</div>
|
||||
<div className="text-white/30 text-[10px]">{lang === 'de' ? 'Compliance + Code-Security + Self-Hosted KI, kein Setup' : 'Compliance + code security + self-hosted AI, no setup fee'}</div>
|
||||
</div>
|
||||
<div className="bg-white/[0.03] rounded-lg p-2">
|
||||
<div className="text-white/50 mb-1">{lang === 'de' ? 'AppSec-Tools (separat)' : 'AppSec Tools (separate)'}</div>
|
||||
<div className="text-white/80 font-medium">$10K – $500K+/yr</div>
|
||||
<div className="text-white/30 text-[10px]">{lang === 'de' ? 'Snyk, Veracode — keine Compliance, Cloud-only' : 'Snyk, Veracode — no compliance, cloud only'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
<p className="text-[10px] text-white/25 text-center mt-3 italic">
|
||||
{lang === 'de'
|
||||
? '~ = geschaetzte Preise (nicht oeffentlich). Alle Preise ohne MwSt. Stand: Q1 2026.'
|
||||
? '~ = geschätzte Preise (nicht öffentlich). Alle Preise ohne MwSt. Stand: Q1 2026.'
|
||||
: '~ = estimated pricing (not public). All prices excl. VAT. As of Q1 2026.'}
|
||||
</p>
|
||||
</FadeInView>
|
||||
@@ -795,8 +746,8 @@ export default function CompetitionSlide({ lang, features, competitors }: Compet
|
||||
</span>
|
||||
<p className="text-white/50 mt-1 leading-relaxed">
|
||||
{lang === 'de'
|
||||
? 'Kein Compliance-Anbieter (Vanta, Drata, etc.) bietet DAST, SAST oder LLM-basierte Code-Fixes. Kein AppSec-Anbieter (Snyk, Veracode, etc.) bietet DSGVO/AI-Act-Compliance. ComplAI ist die einzige Plattform, die beides kombiniert.'
|
||||
: 'No compliance vendor (Vanta, Drata, etc.) offers DAST, SAST, or LLM-based code fixes. No AppSec vendor (Snyk, Veracode, etc.) offers GDPR/AI Act compliance. ComplAI is the only platform combining both.'}
|
||||
? 'Kein Compliance-Anbieter (Vanta, Drata, etc.) bietet DAST, SAST oder LLM-basierte Code-Fixes. Kein AppSec-Anbieter (Snyk, Veracode, etc.) bietet DSGVO/AI-Act-Compliance. COMPLAI ist die einzige Plattform, die beides kombiniert.'
|
||||
: 'No compliance vendor (Vanta, Drata, etc.) offers DAST, SAST, or LLM-based code fixes. No AppSec vendor (Snyk, Veracode, etc.) offers GDPR/AI Act compliance. COMPLAI is the only platform combining both.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -815,20 +766,46 @@ export default function CompetitionSlide({ lang, features, competitors }: Compet
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AppSec Feature Matrix */}
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<SectionHeader
|
||||
label={lang === 'de' ? 'USP — nur ComplAI' : 'USP — ComplAI only'}
|
||||
count={APPSEC_FEATURES.filter(f => f.isUSP).length}
|
||||
open={openSections.has('appsec-usp')}
|
||||
onToggle={() => toggleSection('appsec-usp')}
|
||||
accent="text-indigo-400"
|
||||
/>
|
||||
{openSections.has('appsec-usp') && (
|
||||
<AppSecFeatureTable features={APPSEC_FEATURES.filter(f => f.isUSP)} lang={lang} highlight />
|
||||
)}
|
||||
{/* Efficiency Ratios — AppSec */}
|
||||
<GlassCard className="!p-3 mt-4 mb-4" hover={false}>
|
||||
<h4 className="text-xs font-semibold text-white/60 mb-2 flex items-center gap-1.5">
|
||||
<TrendingUp className="w-3.5 h-3.5" />
|
||||
{lang === 'de' ? 'Effizienz-Kennzahlen' : 'Efficiency Ratios'}
|
||||
</h4>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-[11px]">
|
||||
<thead>
|
||||
<tr className="border-b border-white/10">
|
||||
<th className="text-left py-1.5 px-2 text-white/40 font-medium">{lang === 'de' ? 'Kennzahl' : 'Metric'}</th>
|
||||
{APPSEC_COMPETITORS.map(c => (
|
||||
<th key={c.name} className="py-1.5 px-2 text-white/50 font-medium text-center">{c.flag} {c.name}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className="border-b border-white/5">
|
||||
<td className="py-1.5 px-2 text-white/50">{lang === 'de' ? 'Umsatz / Mitarbeiter' : 'Revenue / Employee'}</td>
|
||||
{APPSEC_COMPETITORS.map(c => (
|
||||
<td key={c.name} className="py-1.5 px-2 text-center text-white/70">
|
||||
${ratio(c.revenueNum, c.employees)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
<tr className="border-b border-white/5">
|
||||
<td className="py-1.5 px-2 text-white/50">{lang === 'de' ? 'Mitarbeiter' : 'Employees'}</td>
|
||||
{APPSEC_COMPETITORS.map(c => (
|
||||
<td key={c.name} className="py-1.5 px-2 text-center text-white/70">
|
||||
{c.employees.toLocaleString()}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
{/* AppSec Feature Matrix */}
|
||||
<div className="space-y-2 mt-4">
|
||||
<div>
|
||||
<SectionHeader
|
||||
label={lang === 'de' ? 'Alle AppSec Features' : 'All AppSec Features'}
|
||||
@@ -926,12 +903,21 @@ function CompetitorCard({ competitor: c, lang }: { competitor: ExtendedCompetito
|
||||
)
|
||||
}
|
||||
|
||||
const GROUP_LABELS: Record<string, { de: string; en: string; color: string }> = {
|
||||
'code-security': { de: 'Code Security & DevSecOps', en: 'Code Security & DevSecOps', color: 'text-red-400' },
|
||||
'ai-data': { de: 'KI & Daten', en: 'AI & Data', color: 'text-purple-400' },
|
||||
'frameworks': { de: 'Regulatorische Frameworks', en: 'Regulatory Frameworks', color: 'text-blue-400' },
|
||||
'documentation': { de: 'Compliance-Dokumentation', en: 'Compliance Documentation', color: 'text-emerald-400' },
|
||||
'operations': { de: 'Operative Compliance', en: 'Operative Compliance', color: 'text-amber-400' },
|
||||
'platform': { de: 'Technische Plattform', en: 'Technical Platform', color: 'text-cyan-400' },
|
||||
'industry': { de: 'Branche & Spezial', en: 'Industry & Specialty', color: 'text-orange-400' },
|
||||
}
|
||||
|
||||
function FeatureTable({
|
||||
features,
|
||||
lang,
|
||||
cols,
|
||||
labels,
|
||||
highlight,
|
||||
}: {
|
||||
features: ComparisonFeature[]
|
||||
lang: Language
|
||||
@@ -939,6 +925,41 @@ function FeatureTable({
|
||||
labels: string[]
|
||||
highlight?: boolean
|
||||
}) {
|
||||
// Build rows with group headers
|
||||
const rowElements: React.ReactNode[] = []
|
||||
let lastGroup = ''
|
||||
features.forEach((f, i) => {
|
||||
const grp = f.group || ''
|
||||
if (grp && grp !== lastGroup) {
|
||||
const gl = GROUP_LABELS[grp]
|
||||
if (gl) {
|
||||
rowElements.push(
|
||||
<tr key={`grp-${grp}`} className="bg-white/[0.02]">
|
||||
<td colSpan={cols.length + 1} className={`py-1.5 px-2 text-[10px] font-bold uppercase tracking-wider ${gl.color}`}>
|
||||
{lang === 'de' ? gl.de : gl.en}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
lastGroup = grp
|
||||
}
|
||||
rowElements.push(
|
||||
<tr key={i} className={`border-b border-white/5 ${f.isDiff ? 'bg-indigo-500/5' : ''}`}>
|
||||
<td className="py-1.5 px-2 flex items-center gap-1.5">
|
||||
{f.isDiff && <Star className="w-3 h-3 text-yellow-400 shrink-0" />}
|
||||
<span className={f.isDiff ? 'text-white font-medium' : 'text-white/60'}>
|
||||
{lang === 'de' ? f.de : f.en}
|
||||
</span>
|
||||
</td>
|
||||
{cols.map(col => (
|
||||
<td key={col} className="py-1.5 px-1.5 text-center">
|
||||
<StatusIcon value={f[col as keyof ComparisonFeature] as FeatureStatus} />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
)
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto mt-1 mb-1">
|
||||
<table className="w-full text-[11px]">
|
||||
@@ -952,23 +973,7 @@ function FeatureTable({
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{features.map((f, i) => (
|
||||
<tr key={i} className={`border-b border-white/5 ${highlight && f.isDiff ? 'bg-indigo-500/5' : ''}`}>
|
||||
<td className="py-1.5 px-2 flex items-center gap-1.5">
|
||||
{f.isDiff && <Star className="w-3 h-3 text-yellow-400 shrink-0" />}
|
||||
<span className={f.isDiff ? 'text-white font-medium' : 'text-white/60'}>
|
||||
{lang === 'de' ? f.de : f.en}
|
||||
</span>
|
||||
</td>
|
||||
{cols.map(col => (
|
||||
<td key={col} className="py-1.5 px-1.5 text-center">
|
||||
<StatusIcon value={f[col as keyof ComparisonFeature] as FeatureStatus} />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tbody>{rowElements}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
'use client'
|
||||
|
||||
import { Language } from '@/lib/types'
|
||||
import GradientText from '../ui/GradientText'
|
||||
import FadeInView from '../ui/FadeInView'
|
||||
import { Shield, Lock } from 'lucide-react'
|
||||
|
||||
interface DisclaimerSlideProps {
|
||||
lang: Language
|
||||
}
|
||||
|
||||
export default function DisclaimerSlide({ lang }: DisclaimerSlideProps) {
|
||||
const de = lang === 'de'
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<FadeInView className="text-center mb-6">
|
||||
<h2 className="text-3xl md:text-4xl font-bold mb-2">
|
||||
<GradientText>{de ? 'Rechtlicher Hinweis' : 'Legal Notice'}</GradientText>
|
||||
</h2>
|
||||
</FadeInView>
|
||||
|
||||
<div className="space-y-4 max-h-[70vh] overflow-y-auto pr-2">
|
||||
|
||||
{/* Disclaimer */}
|
||||
<FadeInView delay={0.1}>
|
||||
<div className="bg-white/[0.03] border border-white/[0.06] rounded-xl p-5">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Shield className="w-4 h-4 text-indigo-400" />
|
||||
<h3 className="text-sm font-bold text-indigo-400 uppercase tracking-wider">
|
||||
{de ? 'Haftungsausschluss' : 'Disclaimer'}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 text-xs text-white/40 leading-relaxed">
|
||||
<p>
|
||||
{de
|
||||
? '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. Die Gründer handeln ausschließlich als Privatpersonen im Rahmen der Gründungsvorbereitung.'
|
||||
: '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 in the commercial register. The Founders act exclusively as private individuals in preparation of the founding.'}
|
||||
</p>
|
||||
<p>
|
||||
{de
|
||||
? 'Dieses Dokument stellt weder ein Angebot zum Verkauf noch eine Aufforderung zur Abgabe eines Angebots zum Erwerb von Wertpapieren, Gesellschaftsanteilen oder sonstigen Vermögensanlagen dar. Es handelt sich nicht um einen Wertpapierprospekt im Sinne des VermAnlG oder der EU-Prospektverordnung. Jede etwaige künftige Beteiligung begründet sich ausschließlich auf gesonderten, rechtlich geprüften Beteiligungsverträgen.'
|
||||
: 'This document constitutes neither an offer to sell nor a solicitation of an offer to acquire securities, company shares or other financial instruments. It is not a securities prospectus within the meaning of the VermAnlG or the EU Prospectus Regulation. Any future participation shall be based exclusively on separate, legally reviewed participation agreements.'}
|
||||
</p>
|
||||
<p>
|
||||
{de
|
||||
? 'Dieses Dokument enthält zukunftsgerichtete Aussagen, die auf gegenwärtigen Erwartungen und Annahmen beruhen. Solche Aussagen beinhalten Risiken und Ungewissheiten, die dazu führen können, dass tatsächliche Ergebnisse wesentlich von den dargestellten Erwartungen abweichen. Sämtliche Finanzangaben in dieser Präsentation sind Planzahlen und stellen keine Garantie für künftige Ergebnisse dar.'
|
||||
: 'This document contains forward-looking statements based on current expectations and assumptions. Such statements involve risks and uncertainties that may cause actual results to differ materially from stated expectations. All financial figures in this presentation are projections and do not constitute a guarantee of future results.'}
|
||||
</p>
|
||||
<p>
|
||||
{de
|
||||
? 'Sämtliche Angaben wurden mit Sorgfalt zusammengestellt, erheben jedoch keinen Anspruch auf Vollständigkeit oder Richtigkeit. Die Gründer übernehmen keine Haftung für die Aktualität, Korrektheit oder Vollständigkeit der Informationen, sofern kein vorsätzliches oder grob fahrlässiges Verschulden vorliegt. Eine Beteiligung an einem jungen Unternehmen ist mit erheblichen Risiken verbunden, einschließlich des Risikos eines Totalverlusts des eingesetzten Kapitals.'
|
||||
: 'All information has been compiled with care but makes no claim to completeness or accuracy. The Founders assume no liability for the timeliness, correctness or completeness of the information, unless there is intentional or grossly negligent fault. An investment in a young company involves significant risks, including the risk of total loss of invested capital.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</FadeInView>
|
||||
|
||||
{/* Confidentiality */}
|
||||
<FadeInView delay={0.2}>
|
||||
<div className="bg-white/[0.03] border border-white/[0.06] rounded-xl p-5">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Lock className="w-4 h-4 text-purple-400" />
|
||||
<h3 className="text-sm font-bold text-purple-400 uppercase tracking-wider">
|
||||
{de ? 'Vertraulichkeit' : 'Confidentiality'}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 text-xs text-white/40 leading-relaxed">
|
||||
<p>
|
||||
{de
|
||||
? '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:'
|
||||
: 'This document is confidential and has been prepared exclusively for the personally invited recipient. By accessing this document, the recipient agrees to the following terms:'}
|
||||
</p>
|
||||
<p>
|
||||
{de
|
||||
? '(a) Geheimhaltung — Der Empfänger verpflichtet sich, den Inhalt vertraulich zu behandeln und nicht an Dritte weiterzugeben, zu kopieren oder zugänglich zu machen. Ausgenommen sind Berater (Rechtsanwälte, Steuerberater), die berufsrechtlich zur Verschwiegenheit verpflichtet sind.'
|
||||
: '(a) Confidentiality — The recipient undertakes to treat the content confidentially and not to disclose, copy or make it accessible to third parties. Excluded are advisors (lawyers, tax advisors) who are professionally bound to secrecy.'}
|
||||
</p>
|
||||
<p>
|
||||
{de
|
||||
? '(b) Zweckbindung — Die Informationen dürfen ausschließlich zur Bewertung einer möglichen Beteiligung verwendet werden. Jede anderweitige Nutzung ist untersagt.'
|
||||
: '(b) Purpose limitation — The information may only be used for the purpose of evaluating a possible participation. Any other use is prohibited.'}
|
||||
</p>
|
||||
<p>
|
||||
{de
|
||||
? '(c) Geltungsdauer — Diese Vertraulichkeitsverpflichtung gilt für drei (3) Jahre ab Übermittlung, unabhängig davon, ob eine Beteiligung zustande kommt. Es gilt deutsches Recht. Gerichtsstand ist Konstanz, Deutschland.'
|
||||
: '(c) Duration — This confidentiality obligation applies for three (3) years from transmission, regardless of whether a participation materializes. German law applies. Place of jurisdiction is Konstanz, Germany.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</FadeInView>
|
||||
|
||||
<FadeInView delay={0.3}>
|
||||
<p className="text-center text-[10px] text-white/20">
|
||||
{de ? 'Stand: April 2026 · Dieser Hinweis ersetzt keine Rechtsberatung.' : 'As of: April 2026 · This notice does not replace legal advice.'}
|
||||
</p>
|
||||
</FadeInView>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -30,33 +30,26 @@ export default function EngineeringSlide({ lang }: EngineeringSlideProps) {
|
||||
|
||||
const heroStats = [
|
||||
{
|
||||
value: '481K',
|
||||
value: '500K+',
|
||||
label: de ? 'Zeilen Code' : 'Lines of Code',
|
||||
sub: 'Go · Python · TypeScript',
|
||||
color: 'text-indigo-400',
|
||||
borderColor: 'border-indigo-500/30',
|
||||
},
|
||||
{
|
||||
value: '10',
|
||||
label: de ? 'Docker Container' : 'Docker Containers',
|
||||
sub: de ? 'Coolify → Hetzner (amd64)' : 'Coolify → Hetzner (amd64)',
|
||||
value: '385',
|
||||
label: de ? 'Dokumente im RAG' : 'Documents in RAG',
|
||||
sub: de ? 'EU · DACH · Frameworks · Urteile' : 'EU · DACH · Frameworks · Rulings',
|
||||
color: 'text-emerald-400',
|
||||
borderColor: 'border-emerald-500/30',
|
||||
},
|
||||
{
|
||||
value: '48+',
|
||||
label: de ? 'SDK-Module' : 'SDK Modules',
|
||||
sub: de ? 'DSGVO · AI Act · NIS2 · CRA' : 'GDPR · AI Act · NIS2 · CRA',
|
||||
value: '25K+',
|
||||
label: de ? 'Compliance Controls' : 'Compliance Controls',
|
||||
sub: de ? '6 Pipeline-Versionen' : '6 pipeline versions',
|
||||
color: 'text-purple-400',
|
||||
borderColor: 'border-purple-500/30',
|
||||
},
|
||||
{
|
||||
value: '14',
|
||||
label: 'Dockerfiles',
|
||||
sub: de ? 'Vollstaendig containerisiert' : 'Fully containerized',
|
||||
color: 'text-amber-400',
|
||||
borderColor: 'border-amber-500/30',
|
||||
},
|
||||
]
|
||||
|
||||
const languageBreakdown = [
|
||||
@@ -69,17 +62,17 @@ export default function EngineeringSlide({ lang }: EngineeringSlideProps) {
|
||||
{
|
||||
icon: GitBranch,
|
||||
label: 'Gitea + Actions',
|
||||
desc: de ? 'Self-hosted Git + CI/CD · Lint → Tests → Validierung' : 'Self-hosted Git + CI/CD · Lint → Tests → Validation',
|
||||
desc: de ? 'Self-hosted Git + CI/CD · Lint → Tests → Image-Build' : 'Self-hosted Git + CI/CD · Lint → Tests → Image build',
|
||||
},
|
||||
{
|
||||
icon: Workflow,
|
||||
label: 'Coolify',
|
||||
desc: de ? 'Auto-Deploy bei Push · Docker Compose auf Hetzner · Health Checks' : 'Auto-deploy on push · Docker Compose on Hetzner · Health checks',
|
||||
label: 'orca',
|
||||
desc: de ? 'Single-Binary Orchestrator (Rust) · Webhook-Deploy · Auto-TLS · Raft' : 'Single-binary orchestrator (Rust) · Webhook deploys · Auto-TLS · Raft',
|
||||
},
|
||||
{
|
||||
icon: Container,
|
||||
label: 'Docker Compose',
|
||||
desc: de ? 'arm64 → amd64 Build-Pipeline · Multi-Stage Builds' : 'arm64 → amd64 build pipeline · Multi-stage builds',
|
||||
label: 'Private Registry',
|
||||
desc: de ? 'registry.meghsakha.com · Signed Images · Tag pro Commit (:SHA + :latest)' : 'registry.meghsakha.com · Signed images · Per-commit tags (:SHA + :latest)',
|
||||
},
|
||||
{
|
||||
icon: ShieldCheck,
|
||||
@@ -88,13 +81,13 @@ export default function EngineeringSlide({ lang }: EngineeringSlideProps) {
|
||||
},
|
||||
{
|
||||
icon: Database,
|
||||
label: 'HashiCorp Vault',
|
||||
desc: de ? 'Secrets Management · Auto-Rotation · PKI' : 'Secrets Management · Auto-Rotation · PKI',
|
||||
label: 'Infisical',
|
||||
desc: de ? 'Secrets Management · Rotation · RBAC · End-to-End verschlüsselt' : 'Secrets Management · Rotation · RBAC · End-to-end encrypted',
|
||||
},
|
||||
{
|
||||
icon: Server,
|
||||
label: de ? 'EU-Cloud Infrastruktur' : 'EU Cloud Infrastructure',
|
||||
desc: de ? 'Hetzner · SysEleven (BSI) · OVH · PostgreSQL · Qdrant' : 'Hetzner · SysEleven (BSI) · OVH · PostgreSQL · Qdrant',
|
||||
desc: de ? 'Hetzner · SysEleven (BSI) · PostgreSQL · Qdrant' : 'Hetzner · SysEleven (BSI) · PostgreSQL · Qdrant',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -120,8 +113,8 @@ export default function EngineeringSlide({ lang }: EngineeringSlideProps) {
|
||||
color: 'text-emerald-400',
|
||||
dotColor: 'bg-emerald-400',
|
||||
services: de
|
||||
? ['PostgreSQL 17 (Hetzner)', 'Qdrant Vector DB', 'DSMS/IPFS Node + Gateway', 'MinIO Object Storage']
|
||||
: ['PostgreSQL 17 (Hetzner)', 'Qdrant Vector DB', 'DSMS/IPFS Node + Gateway', 'MinIO Object Storage'],
|
||||
? ['orca (Rust) Orchestrator', 'Infisical Secrets', 'PostgreSQL 17 (Hetzner)', 'Qdrant Vector DB', 'DSMS/IPFS Node + Gateway', 'Private Registry']
|
||||
: ['orca (Rust) Orchestrator', 'Infisical Secrets', 'PostgreSQL 17 (Hetzner)', 'Qdrant Vector DB', 'DSMS/IPFS Node + Gateway', 'Private Registry'],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -139,7 +132,7 @@ export default function EngineeringSlide({ lang }: EngineeringSlideProps) {
|
||||
|
||||
{/* Hero Stats */}
|
||||
<FadeInView delay={0.1}>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-5">
|
||||
<div className="grid grid-cols-3 gap-3 mb-5">
|
||||
{heroStats.map((stat, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
@@ -261,8 +254,8 @@ export default function EngineeringSlide({ lang }: EngineeringSlideProps) {
|
||||
<div className="mt-3 pt-3 border-t border-white/5">
|
||||
<p className="text-[10px] text-white/20 text-center">
|
||||
{de
|
||||
? '100% EU-Cloud · Hetzner + SysEleven (BSI) + OVH · Keine US-Anbieter · Volle Datenkontrolle'
|
||||
: '100% EU Cloud · Hetzner + SysEleven (BSI) + OVH · No US Providers · Full Data Control'}
|
||||
? '100% EU-Cloud · Hetzner + SysEleven (BSI) · Keine US-Anbieter · Volle Datenkontrolle'
|
||||
: '100% EU Cloud · Hetzner + SysEleven (BSI) · No US Providers · Full Data Control'}
|
||||
</p>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Language, PitchData } from '@/lib/types'
|
||||
import { t, formatEur } from '@/lib/i18n'
|
||||
import { useFpKPIs } from '@/lib/hooks/useFpKPIs'
|
||||
import GradientText from '../ui/GradientText'
|
||||
import FadeInView from '../ui/FadeInView'
|
||||
import GlassCard from '../ui/GlassCard'
|
||||
@@ -11,13 +12,25 @@ import { Download, Shield, Server, Brain, TrendingUp, FileText, Target, ScanLine
|
||||
interface ExecutiveSummarySlideProps {
|
||||
lang: Language
|
||||
data: PitchData
|
||||
investorId?: string | null
|
||||
preferredScenarioId?: string | null
|
||||
isWandeldarlehen?: boolean
|
||||
}
|
||||
|
||||
export default function ExecutiveSummarySlide({ lang, data }: ExecutiveSummarySlideProps) {
|
||||
export default function ExecutiveSummarySlide({ lang, data, investorId, preferredScenarioId, isWandeldarlehen }: ExecutiveSummarySlideProps) {
|
||||
const i = t(lang)
|
||||
const es = i.executiveSummary
|
||||
const de = lang === 'de'
|
||||
|
||||
// Unternehmensentwicklung from fp_* tables (source of truth)
|
||||
const { kpis: fpKPIs } = useFpKPIs(isWandeldarlehen)
|
||||
|
||||
// Pipeline stats from DB
|
||||
const [pipelineStats, setPipelineStats] = useState<Record<string, { value: number }>>({})
|
||||
useEffect(() => {
|
||||
fetch('/api/pipeline-stats', { cache: 'no-store' }).then(r => r.json()).then(setPipelineStats).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const funding = data.funding
|
||||
const amount = funding?.amount_eur || 0
|
||||
const amountLabel = amount >= 1_000_000
|
||||
@@ -165,7 +178,7 @@ export default function ExecutiveSummarySlide({ lang, data }: ExecutiveSummarySl
|
||||
|
||||
<div style="display:grid;grid-template-columns:repeat(5,1fr);gap:8px;margin-bottom:10px;">
|
||||
<div class="kpi"><div class="value">25.000+</div><div class="label">${es.controls}</div></div>
|
||||
<div class="kpi"><div class="value">110</div><div class="label">${es.regulations}</div></div>
|
||||
<div class="kpi"><div class="value">380+</div><div class="label">${es.regulations}</div></div>
|
||||
<div class="kpi"><div class="value">10</div><div class="label">${es.industries}</div></div>
|
||||
<div class="kpi"><div class="value">500K+</div><div class="label">${es.linesOfCode}</div></div>
|
||||
<div class="kpi"><div class="value">${amountLabel}</div><div class="label">${es.theAsk}</div></div>
|
||||
@@ -179,7 +192,7 @@ export default function ExecutiveSummarySlide({ lang, data }: ExecutiveSummarySl
|
||||
<li><strong>SAST + DAST + SBOM</strong> ${de ? '\\u2014 Vollumf\\u00e4ngliche Sicherheitstests bei jeder Code-\\u00c4nderung' : '\\u2014 Full security testing on every code change'}</li>
|
||||
<li><strong>${de ? 'KI-gest\\u00fctztes Pentesting' : 'AI-powered Pentesting'}</strong> ${de ? '\\u2014 Kontinuierlich statt einmal im Jahr' : '\\u2014 Continuous instead of once a year'}</li>
|
||||
<li><strong>CE-Software-Risikobeurteilung</strong> ${de ? '\\u2014 F\\u00fcr Maschinenverordnung und Produktsicherheit' : '\\u2014 For Machinery Regulation and product safety'}</li>
|
||||
<li><strong>Jira-Integration</strong> ${de ? '\\u2014 Findings als Tickets mit Implementierungsvorschl\\u00e4gen' : '\\u2014 Findings as tickets with implementation suggestions'}</li>
|
||||
<li><strong>Issue-Tracker-Integration</strong> ${de ? '\\u2014 Findings als Tickets mit Implementierungsvorschl\\u00e4gen' : '\\u2014 Findings as tickets with implementation suggestions'}</li>
|
||||
<li><strong>Audit-Trail</strong> ${de ? '\\u2014 L\\u00fcckenloser Nachweis von Erkennung bis Behebung' : '\\u2014 Complete evidence from detection to remediation'}</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -191,7 +204,7 @@ export default function ExecutiveSummarySlide({ lang, data }: ExecutiveSummarySl
|
||||
<li><strong>Audit Manager</strong> ${de ? '\\u2014 Abweichungen End-to-End: Rollen, Stichtage, Eskalation' : '\\u2014 Deviations end-to-end: roles, deadlines, escalation'}</li>
|
||||
<li><strong>Compliance LLM</strong> ${de ? '\\u2014 GPT f\\u00fcr Text und Audio, sicher in der EU gehostet' : '\\u2014 GPT for text and audio, securely hosted in EU'}</li>
|
||||
<li><strong>Academy</strong> ${de ? '\\u2014 Online-Schulungen f\\u00fcr GF und Mitarbeiter' : '\\u2014 Online training for management and employees'}</li>
|
||||
<li><strong>${de ? 'BSI-Cloud DE / OVH FR' : 'BSI Cloud DE / OVH FR'}</strong> ${de ? '\\u2014 Keine US-SaaS, Jitsi, Matrix, volle Integration' : '\\u2014 No US SaaS, Jitsi, Matrix, full integration'}</li>
|
||||
<li><strong>${de ? 'BSI-Cloud DE / FR' : 'BSI Cloud DE / FR'}</strong> ${de ? '\\u2014 Keine US-SaaS, Jitsi, Matrix, volle Integration' : '\\u2014 No US SaaS, Jitsi, Matrix, full integration'}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -208,9 +221,9 @@ export default function ExecutiveSummarySlide({ lang, data }: ExecutiveSummarySl
|
||||
<div class="card bottom-card">
|
||||
<div class="section-title">${de ? 'Gesch\\u00e4ftsmodell' : 'Business Model'}</div>
|
||||
<ul>
|
||||
<li><strong>SaaS Cloud</strong> ${de ? '\\u2014 BSI DE / OVH FR, mitarbeiterbasiert' : '\\u2014 BSI DE / OVH FR, employee-based'}</li>
|
||||
<li><strong>SaaS Cloud</strong> ${de ? '\\u2014 BSI DE / FR, mitarbeiterbasiert' : '\\u2014 BSI DE / FR, employee-based'}</li>
|
||||
<li><strong>${de ? 'Modular w\\u00e4hlbar' : 'Modular choice'}</strong> ${de ? '\\u2014 Einzelne Module oder Full Compliance' : '\\u2014 Single modules or full compliance'}</li>
|
||||
<li><strong>${de ? 'ROI ab Tag 1' : 'ROI from day 1'}</strong> ${de ? '\\u2014 Kunde spart 50.000+ EUR/Jahr' : '\\u2014 Customer saves EUR 50,000+/year'}</li>
|
||||
<li><strong>${de ? 'ROI ab Tag 1' : 'ROI from day 1'}</strong> ${de ? '\\u2014 KMU spart 55.000 EUR/Jahr (3,7x ROI)' : '\\u2014 SME saves EUR 55,000/year (3.7x ROI)'}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card bottom-card">
|
||||
@@ -278,17 +291,28 @@ export default function ExecutiveSummarySlide({ lang, data }: ExecutiveSummarySl
|
||||
</div>
|
||||
</FadeInView>
|
||||
|
||||
{/* USP Banner */}
|
||||
{/* USP / MOAT */}
|
||||
<FadeInView delay={0.1} className="mb-4">
|
||||
<div className="bg-gradient-to-r from-indigo-500/20 to-purple-500/20 border border-indigo-500/30 rounded-2xl px-5 py-3 text-center">
|
||||
<span className="text-base font-bold text-indigo-400 uppercase tracking-wider">{es.usp}</span>
|
||||
<p className="text-sm text-white/80 mt-2 leading-relaxed">
|
||||
{de
|
||||
? 'Die einzige Plattform, die kontinuierliche Code-Security, automatisierte Compliance-Dokumentation und CE-konforme Software-Risikobeurteilung in einem System vereint – vollständig betrieben auf europäischer Infrastruktur (Deutschland oder Frankreich).'
|
||||
: 'The only platform combining continuous code security, automated compliance documentation and CE-compliant software risk assessment in one system – fully operated on European infrastructure (Germany or France).'
|
||||
}
|
||||
</p>
|
||||
<p className="text-sm font-semibold text-indigo-300 mt-1.5">{de ? '100\u00a0% Datensouveränität ohne Abhängigkeit von US-Anbietern.' : '100% data sovereignty without dependence on US providers.'}</p>
|
||||
<div className="bg-gradient-to-r from-indigo-500/10 to-purple-500/10 border border-indigo-500/20 rounded-2xl px-4 py-3">
|
||||
<span className="text-[10px] font-bold text-indigo-400 uppercase tracking-wider">{de ? 'Unser MOAT' : 'Our MOAT'}</span>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 mt-2">
|
||||
<div className="text-center">
|
||||
<p className="text-[10px] font-bold text-indigo-300">Traceability</p>
|
||||
<p className="text-[9px] text-white/40">{de ? 'Gesetz → Control → Code' : 'Law → Control → Code'}</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-[10px] font-bold text-purple-300">Continuous Engine</p>
|
||||
<p className="text-[9px] text-white/40">{de ? 'Echtzeit bei jeder Änderung' : 'Real-time on every change'}</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-[10px] font-bold text-amber-300">Compliance Optimizer</p>
|
||||
<p className="text-[9px] text-white/40">{de ? 'Maximale KI-Nutzung im Rahmen' : 'Max AI use within regulations'}</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-[10px] font-bold text-emerald-300">EU-Trust Stack</p>
|
||||
<p className="text-[9px] text-white/40">{de ? '100% EU, kein US-SaaS' : '100% EU, no US SaaS'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FadeInView>
|
||||
|
||||
@@ -364,8 +388,8 @@ export default function ExecutiveSummarySlide({ lang, data }: ExecutiveSummarySl
|
||||
<FadeInView delay={0.25} className="mb-4">
|
||||
<div className="grid grid-cols-3 md:grid-cols-6 gap-2">
|
||||
{[
|
||||
{ value: '25.000+', label: es.controls, icon: Shield, color: '#6366f1' },
|
||||
{ value: '110', label: es.regulations, icon: Brain, color: '#60a5fa' },
|
||||
{ value: pipelineStats.unique_controls ? `${Math.round(pipelineStats.unique_controls.value / 1000)}k+` : '25k+', label: es.controls, icon: Shield, color: '#6366f1' },
|
||||
{ value: pipelineStats.legal_sources ? `${pipelineStats.legal_sources.value}+` : '380+', label: es.regulations, icon: Brain, color: '#60a5fa' },
|
||||
{ value: '10', label: es.industries, icon: Target, color: '#34d399' },
|
||||
{ value: '500K+', label: es.linesOfCode, icon: Cpu, color: '#fbbf24' },
|
||||
{ value: '80%', label: de ? 'Zeitersparnis bei\nCompliance-Prüfungen' : 'Time saved on\ncompliance checks', icon: TrendingUp, color: '#10b981' },
|
||||
@@ -393,7 +417,7 @@ export default function ExecutiveSummarySlide({ lang, data }: ExecutiveSummarySl
|
||||
de ? 'SAST + DAST + SBOM — bei jeder Code-Änderung' : 'SAST + DAST + SBOM — on every code change',
|
||||
de ? 'KI-gestütztes Pentesting — kontinuierlich statt jährlich' : 'AI-powered pentesting — continuous not annual',
|
||||
de ? 'CE-Software-Risikobeurteilung für Maschinenverordnung' : 'CE software risk assessment for Machinery Regulation',
|
||||
de ? 'Integration in Kundenprozesse — Tickets mit Implementierungsvorschlägen' : 'Integration into customer processes — tickets with implementation suggestions',
|
||||
de ? 'Compliance Optimizer + Tender Matching gegen Codebase' : 'Compliance Optimizer + Tender Matching against codebase',
|
||||
de ? 'Lückenloser Audit-Trail von Erkennung bis Behebung' : 'Complete audit trail from detection to remediation',
|
||||
].map((item, idx) => (
|
||||
<p key={idx} className="text-xs text-white/60 pl-3 relative">
|
||||
@@ -416,7 +440,7 @@ export default function ExecutiveSummarySlide({ lang, data }: ExecutiveSummarySl
|
||||
de ? 'Audit Manager — Abweichungen End-to-End mit Eskalation' : 'Audit Manager — deviations end-to-end with escalation',
|
||||
de ? 'Compliance LLM — GPT für Text und Audio, EU-gehostet' : 'Compliance LLM — GPT for text and audio, EU-hosted',
|
||||
de ? 'Academy — Online-Schulungen für GF und Mitarbeiter' : 'Academy — online training for management and employees',
|
||||
de ? 'BSI-Cloud DE / OVH FR' : 'BSI Cloud DE / OVH FR',
|
||||
de ? 'BSI-Cloud DE / FR' : 'BSI Cloud DE / FR',
|
||||
].map((item, idx) => (
|
||||
<p key={idx} className="text-xs text-white/60 pl-3 relative">
|
||||
<span className="absolute left-0 top-1 w-1.5 h-1.5 rounded-full bg-cyan-400/60" />
|
||||
@@ -464,9 +488,9 @@ export default function ExecutiveSummarySlide({ lang, data }: ExecutiveSummarySl
|
||||
{ name: 'Consent', desc: de ? 'Einwilligungen' : 'Consent mgmt', color: '#14b8a6', icon: UserCheck },
|
||||
{ name: de ? 'Notfallpläne' : 'Incident Resp.', desc: de ? 'Vorfälle, Meldung' : 'Breaches, reporting', color: '#f59e0b', icon: AlertTriangle },
|
||||
{ name: 'Compliance LLM', desc: de ? 'GPT Text + Audio' : 'GPT text + audio', color: '#a855f7', icon: Brain },
|
||||
{ name: 'Cookie-Generator', desc: de ? 'Cookie-Banner' : 'Cookie banner', color: '#8b5cf6', icon: Shield },
|
||||
{ name: 'Tender Matching', desc: de ? 'RFQ gegen Codebase' : 'RFQ vs codebase', color: '#8b5cf6', icon: Shield },
|
||||
{ name: 'Academy', desc: de ? 'Schulungen' : 'Training', color: '#ec4899', icon: GraduationCap },
|
||||
{ name: de ? 'Integration' : 'Integration', desc: de ? 'Ticketsysteme' : 'Ticket systems', color: '#0ea5e9', icon: Cpu },
|
||||
{ name: 'Compliance Optimizer', desc: de ? 'Maximale KI-Nutzung im Rahmen' : 'Max AI usage within limits', color: '#0ea5e9', icon: Cpu },
|
||||
{ name: de ? 'Kommunikation' : 'Communication', desc: de ? 'Chat + Video + AI' : 'Chat + video + AI', color: '#22c55e', icon: Server },
|
||||
].map((mod, idx) => {
|
||||
const Icon = mod.icon
|
||||
@@ -527,45 +551,47 @@ export default function ExecutiveSummarySlide({ lang, data }: ExecutiveSummarySl
|
||||
<span>{de ? 'Jahr' : 'Year'}</span><span className="text-right">MA</span><span className="text-right">{de ? 'Kunden' : 'Customers'}</span><span className="text-right">ARR</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{[
|
||||
{ year: '2026', emp: '5', cust: '~17', arr: de ? '~84k EUR' : '~EUR 84k' },
|
||||
{ year: '2027', emp: '10', cust: '~132', arr: de ? '~1,1 Mio. EUR' : '~EUR 1.1M' },
|
||||
{ year: '2028', emp: '17', cust: '~400', arr: de ? '~3,6 Mio. EUR' : '~EUR 3.6M' },
|
||||
{ year: '2029', emp: '25', cust: '~780', arr: de ? '~6,9 Mio. EUR' : '~EUR 6.9M' },
|
||||
{ year: '2030', emp: '35', cust: '~1.320', arr: de ? '~11,1 Mio. EUR' : '~EUR 11.1M' },
|
||||
].map((r, idx) => (
|
||||
<div key={idx} className="grid grid-cols-4 gap-x-3 text-xs">
|
||||
<span className="text-white/40">{r.year}</span>
|
||||
<span className="text-right text-white/50">{r.emp}</span>
|
||||
<span className="text-right text-white/50">{r.cust}</span>
|
||||
<span className={`text-right font-mono ${idx >= 3 ? 'text-emerald-300 font-bold' : 'text-white/70'}`}>{r.arr}</span>
|
||||
</div>
|
||||
))}
|
||||
{!fpKPIs.y2026 ? (
|
||||
<p className="text-xs text-white/30 text-center py-2">{de ? 'Lade Finanzplan...' : 'Loading financial plan...'}</p>
|
||||
) : [2026, 2027, 2028, 2029, 2030].map((year, idx) => {
|
||||
const k = fpKPIs[`y${year}`]
|
||||
if (!k) return null
|
||||
const arrLabel = k.arr >= 1_000_000
|
||||
? (de ? `~${(k.arr / 1_000_000).toFixed(1).replace('.', ',')} Mio. EUR` : `~EUR ${(k.arr / 1_000_000).toFixed(1)}M`)
|
||||
: (de ? `~${Math.round(k.arr / 1000)}k EUR` : `~EUR ${Math.round(k.arr / 1000)}k`)
|
||||
return (
|
||||
<div key={idx} className="grid grid-cols-4 gap-x-3 text-xs">
|
||||
<span className="text-white/40">{year}</span>
|
||||
<span className="text-right text-white/50">{k.headcount}</span>
|
||||
<span className="text-right text-white/50">~{k.customers.toLocaleString('de-DE')}</span>
|
||||
<span className={`text-right font-mono ${idx >= 3 ? 'text-emerald-300 font-bold' : 'text-white/70'}`}>{arrLabel}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
<GlassCard delay={0.55} hover={false} className="p-3 flex-1">
|
||||
<h3 className="text-xs font-bold text-red-400 uppercase tracking-wider mb-2">{de ? 'Wettbewerber' : 'Competitors'}</h3>
|
||||
<div className="grid grid-cols-6 gap-x-2 text-[8px] text-white/30 uppercase tracking-wider mb-1.5 border-b border-white/10 pb-1">
|
||||
<span></span><span>{de ? 'Gegr.' : 'Est.'}</span><span>MA</span><span className="text-right">{de ? 'Kunden' : 'Cust.'}</span><span className="text-right">ARR</span><span className="text-right">Invest</span>
|
||||
<div className="grid grid-cols-5 gap-x-3 text-[9px] text-white/30 uppercase tracking-wider mb-1.5 border-b border-white/10 pb-1">
|
||||
<span></span><span>{de ? 'Gegr.' : 'Est.'}</span><span>MA</span><span className="text-right">{de ? 'Kunden' : 'Cust.'}</span><span className="text-right">ARR</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="space-y-1.5">
|
||||
{[
|
||||
{ name: 'Vanta', flag: '🇺🇸', year: '2018', emp: '500+', cust: '8.000+', rev: '$220M', invest: '$504M' },
|
||||
{ name: 'Drata', flag: '🇺🇸', year: '2020', emp: '500+', cust: '5.000+', rev: '$100M', invest: '$328M' },
|
||||
{ name: 'Sprinto', flag: '🇮🇳', year: '2020', emp: '345', cust: '2.000+', rev: '$38M', invest: '$32M' },
|
||||
{ name: 'Delve', flag: '🇺🇸', year: '2024', emp: '24', cust: '—', rev: '$2,6M', invest: '$35M' },
|
||||
{ name: 'DataGuard', flag: '🇩🇪', year: '2017', emp: '400+', cust: '4.000+', rev: '€20-30M', invest: '€65M' },
|
||||
{ name: 'Proliance', flag: '🇩🇪', year: '2017', emp: '100+', cust: '2.500+', rev: '€5-10M', invest: 'k.A.' },
|
||||
{ name: 'heyData', flag: '🇩🇪', year: '2019', emp: '80+', cust: '2.000+', rev: '€3-10M', invest: '€18M' },
|
||||
{ name: 'Vanta', flag: '🇺🇸', year: '2018', emp: '500+', cust: '8.000+', rev: '$220M' },
|
||||
{ name: 'Drata', flag: '🇺🇸', year: '2020', emp: '500+', cust: '5.000+', rev: '$100M' },
|
||||
{ name: 'Sprinto', flag: '🇮🇳', year: '2020', emp: '345', cust: '2.000+', rev: '$38M' },
|
||||
{ name: 'Delve', flag: '🇺🇸', year: '2024', emp: '24', cust: '—', rev: '$2,6M' },
|
||||
{ name: 'DataGuard', flag: '🇩🇪', year: '2017', emp: '400+', cust: '4.000+', rev: '€20-30M' },
|
||||
{ name: 'Proliance', flag: '🇩🇪', year: '2017', emp: '100+', cust: '2.500+', rev: '€5-10M' },
|
||||
{ name: 'heyData', flag: '🇩🇪', year: '2019', emp: '80+', cust: '2.000+', rev: '€3-10M' },
|
||||
].map((c, idx) => (
|
||||
<div key={idx} className="grid grid-cols-6 gap-x-2 text-[9px]">
|
||||
<span className="text-white/70">{c.flag} {c.name}</span>
|
||||
<span className="text-white/30">{c.year}</span>
|
||||
<span className="text-white/40">{c.emp}</span>
|
||||
<span className="text-right text-white/50">{c.cust}</span>
|
||||
<span className="text-right text-white/50">{c.rev}</span>
|
||||
<span className="text-right text-white/60 font-mono">{c.invest}</span>
|
||||
<div key={idx} className="grid grid-cols-5 gap-x-3 text-[10px]">
|
||||
<span className="text-white/70 font-medium">{c.flag} {c.name}</span>
|
||||
<span className="text-white/40">{c.year}</span>
|
||||
<span className="text-white/50">{c.emp}</span>
|
||||
<span className="text-right text-white/60">{c.cust}</span>
|
||||
<span className="text-right text-white/70 font-mono font-semibold">{c.rev}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -578,10 +604,9 @@ export default function ExecutiveSummarySlide({ lang, data }: ExecutiveSummarySl
|
||||
<h3 className="text-xs font-bold text-amber-400 uppercase tracking-wider mb-2">{de ? 'Pricing' : 'Pricing'}</h3>
|
||||
<div className="space-y-1.5">
|
||||
{[
|
||||
{ tier: 'Startup', price: de ? 'ab 3.600€/J.' : 'from €3,600/yr' },
|
||||
{ tier: '10–50 MA', price: de ? 'ab 15.000€/J.' : 'from €15k/yr' },
|
||||
{ tier: '50–250 MA', price: de ? 'ab 30.000€/J.' : 'from €30k/yr' },
|
||||
{ tier: '250+ MA', price: de ? 'ab 40.000€/J.' : 'from €40k/yr', highlight: true },
|
||||
{ tier: de ? 'Starter (<10 MA)' : 'Starter (<10 emp.)', price: de ? '3.600€/J.' : '€3,600/yr' },
|
||||
{ tier: de ? 'Professional (10–250)' : 'Professional (10–250)', price: de ? '15.000–40.000€/J.' : '€15k–40k/yr', highlight: true },
|
||||
{ tier: de ? 'Enterprise (250+)' : 'Enterprise (250+)', price: de ? 'ab 50.000€/J.' : 'from €50k/yr' },
|
||||
].map((t, idx) => (
|
||||
<div key={idx} className={`flex justify-between text-xs ${t.highlight ? 'text-amber-300 font-bold' : 'text-white/60'}`}>
|
||||
<span>{t.tier}</span>
|
||||
@@ -593,11 +618,12 @@ export default function ExecutiveSummarySlide({ lang, data }: ExecutiveSummarySl
|
||||
|
||||
<GlassCard delay={0.65} hover={false} className="p-3">
|
||||
<h3 className="text-xs font-bold text-emerald-400 uppercase tracking-wider mb-2">{de ? 'Kundenersparnis' : 'Customer Savings'}</h3>
|
||||
<div className="space-y-1.5 text-xs text-white/60">
|
||||
<div className="flex justify-between"><span>Pentests</span><strong className="text-emerald-300">30k</strong></div>
|
||||
<div className="flex justify-between"><span>CE-Beurt.</span><strong className="text-emerald-300">20k</strong></div>
|
||||
<div className="flex justify-between"><span>Audit Mgr.</span><strong className="text-emerald-300">60k+</strong></div>
|
||||
<div className="flex justify-between border-t border-white/10 pt-1 mt-1"><span className="font-bold text-white/80">{de ? 'pro Jahr' : '/year'}</span><strong className="text-emerald-300">50-110k</strong></div>
|
||||
<div className="space-y-1 text-xs text-white/60">
|
||||
<div className="flex justify-between"><span>Pentests</span><strong className="text-emerald-300">13k</strong></div>
|
||||
<div className="flex justify-between"><span>CE-Risiko</span><strong className="text-emerald-300">9k</strong></div>
|
||||
<div className="flex justify-between"><span>{de ? 'Compliance-Zeit' : 'Compliance time'}</span><strong className="text-emerald-300">15k</strong></div>
|
||||
<div className="flex justify-between"><span>{de ? 'Audit-Vorb.' : 'Audit prep.'}</span><strong className="text-emerald-300">9k</strong></div>
|
||||
<div className="flex justify-between border-t border-white/10 pt-1 mt-1"><span className="font-bold text-white/80">{de ? 'KMU/Jahr' : 'SME/year'}</span><strong className="text-emerald-300">55k</strong></div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
@@ -607,10 +633,10 @@ export default function ExecutiveSummarySlide({ lang, data }: ExecutiveSummarySl
|
||||
{/* Disclaimer */}
|
||||
<FadeInView delay={0.7} className="mb-4">
|
||||
<div className="bg-white/[0.03] border border-white/[0.03] rounded-lg px-4 py-3">
|
||||
<h4 className="text-[10px] font-bold text-white/30 uppercase tracking-wider mb-1">{de ? 'Hinweis / Haftungsausschluss' : 'Disclaimer'}</h4>
|
||||
<p className="text-[9px] text-white/20 leading-relaxed">
|
||||
<h4 className="text-xs font-bold text-white/40 uppercase tracking-wider mb-1">{de ? 'Hinweis / Haftungsausschluss' : 'Disclaimer'}</h4>
|
||||
<p className="text-[11px] text-white/30 leading-relaxed">
|
||||
{de
|
||||
? 'Dieses Dokument dient ausschliesslich Informationszwecken und stellt weder ein Angebot zum Verkauf noch eine Aufforderung zum Kauf von Anteilen oder Wertpapieren dar. Die enthaltenen Informationen wurden vom Team Breakpilot (Gruenderteam, noch keine Gesellschaft gegruendet) nach bestem Wissen und Gewissen erstellt, koennen jedoch unvollstaendig sein und jederzeit ohne vorherige Ankuendigung geaendert werden. Es wird keine ausdrueckliche oder konkludente Gewaehr fuer die Richtigkeit, Vollstaendigkeit oder Aktualitaet der Inhalte uebernommen. Dieses Dokument enthaelt zukunftsgerichtete Aussagen, die auf aktuellen Annahmen und Erwartungen beruhen und mit erheblichen Risiken und Unsicherheiten verbunden sind. Die tatsaechlichen Ergebnisse koennen wesentlich von den dargestellten abweichen. Eine Investitionsentscheidung sollte ausschliesslich auf Grundlage weitergehender, rechtlich verbindlicher Unterlagen sowie unter Hinzuziehung eigener rechtlicher, steuerlicher und finanzieller Beratung getroffen werden. Soweit gesetzlich zulaessig, wird jede Haftung des Team Breakpilot sowie seiner Mitglieder fuer etwaige Schaeden, die direkt oder indirekt aus der Nutzung dieses Dokuments entstehen, ausgeschlossen. Dieses Dokument ist vertraulich und ausschliesslich fuer den vorgesehenen Empfaenger bestimmt. Eine Weitergabe, Vervielfaeltigung oder Veroeffentlichung ist ohne vorherige schriftliche Zustimmung nicht gestattet.'
|
||||
? 'Dieses Dokument dient ausschließlich Informationszwecken und stellt weder ein Angebot zum Verkauf noch eine Aufforderung zum Kauf von Anteilen oder Wertpapieren dar. Die enthaltenen Informationen wurden vom Team Breakpilot (Gründerteam, noch keine Gesellschaft gegründet) nach bestem Wissen und Gewissen erstellt, können jedoch unvollständig sein und jederzeit ohne vorherige Ankündigung geändert werden. Es wird keine ausdrückliche oder konkludente Gewähr für die Richtigkeit, Vollständigkeit oder Aktualität der Inhalte übernommen. Dieses Dokument enthält zukunftsgerichtete Aussagen, die auf aktuellen Annahmen und Erwartungen beruhen und mit erheblichen Risiken und Unsicherheiten verbunden sind. Die tatsächlichen Ergebnisse können wesentlich von den dargestellten abweichen. Eine Investitionsentscheidung sollte ausschließlich auf Grundlage weitergehender, rechtlich verbindlicher Unterlagen sowie unter Hinzuziehung eigener rechtlicher, steuerlicher und finanzieller Beratung getroffen werden. Soweit gesetzlich zulässig, wird jede Haftung des Team Breakpilot sowie seiner Mitglieder für etwaige Schäden, die direkt oder indirekt aus der Nutzung dieses Dokuments entstehen, ausgeschlossen. Dieses Dokument ist vertraulich und ausschließlich für den vorgesehenen Empfänger bestimmt. Eine Weitergabe, Vervielfältigung oder Veröffentlichung ist ohne vorherige schriftliche Zustimmung nicht gestattet.'
|
||||
: 'This document is for informational purposes only and does not constitute an offer to sell or a solicitation to purchase shares or securities. The information contained herein was prepared by Team Breakpilot (founding team, no company incorporated yet) to the best of their knowledge, but may be incomplete and subject to change without prior notice. No express or implied warranty is given for the accuracy, completeness or timeliness of the content. This document contains forward-looking statements based on current assumptions and expectations that involve significant risks and uncertainties. Actual results may differ materially. Any investment decision should be based solely on further legally binding documents and with the advice of independent legal, tax and financial counsel. To the extent permitted by law, all liability of Team Breakpilot and its members for any damages arising directly or indirectly from the use of this document is excluded. This document is confidential and intended solely for the designated recipient. Distribution, reproduction or publication without prior written consent is prohibited.'
|
||||
}
|
||||
</p>
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
import { useState } from 'react'
|
||||
import { Language } from '@/lib/types'
|
||||
import { t } from '@/lib/i18n'
|
||||
import ProjectionFooter from '../ui/ProjectionFooter'
|
||||
import { useFinancialModel } from '@/lib/hooks/useFinancialModel'
|
||||
import { useFpKPIs } from '@/lib/hooks/useFpKPIs'
|
||||
import GradientText from '../ui/GradientText'
|
||||
import FadeInView from '../ui/FadeInView'
|
||||
import FinancialChart from '../ui/FinancialChart'
|
||||
@@ -21,11 +23,13 @@ type FinTab = 'overview' | 'guv' | 'cashflow'
|
||||
interface FinancialsSlideProps {
|
||||
lang: Language
|
||||
investorId: string | null
|
||||
preferredScenarioId?: string | null
|
||||
isWandeldarlehen?: boolean
|
||||
}
|
||||
|
||||
export default function FinancialsSlide({ lang, investorId }: FinancialsSlideProps) {
|
||||
export default function FinancialsSlide({ lang, investorId, preferredScenarioId, isWandeldarlehen }: FinancialsSlideProps) {
|
||||
const i = t(lang)
|
||||
const fm = useFinancialModel(investorId)
|
||||
const fm = useFinancialModel(investorId, preferredScenarioId)
|
||||
const [activeTab, setActiveTab] = useState<FinTab>('overview')
|
||||
const de = lang === 'de'
|
||||
|
||||
@@ -33,6 +37,18 @@ export default function FinancialsSlide({ lang, investorId }: FinancialsSlidePro
|
||||
const summary = activeResults?.summary
|
||||
const lastResult = activeResults?.results[activeResults.results.length - 1]
|
||||
|
||||
// KPI cards from fp_* tables (source of truth)
|
||||
const { last: fpLast, kpis: fpKPIs } = useFpKPIs(isWandeldarlehen)
|
||||
const kpiArr = fpLast?.arr || summary?.final_arr || 0
|
||||
const kpiCustomers = fpLast?.customers || summary?.final_customers || 0
|
||||
const kpiEbit = fpKPIs?.y2029?.ebit // First profitable year
|
||||
const kpiBreakEven = (() => {
|
||||
for (const y of [2026, 2027, 2028, 2029, 2030]) {
|
||||
if ((fpKPIs[`y${y}`]?.ebit || 0) > 0) return y
|
||||
}
|
||||
return 0
|
||||
})()
|
||||
|
||||
// Build scenario color map
|
||||
const scenarioColors: Record<string, string> = {}
|
||||
fm.scenarios.forEach(s => { scenarioColors[s.id] = s.color })
|
||||
@@ -46,7 +62,7 @@ export default function FinancialsSlide({ lang, investorId }: FinancialsSlidePro
|
||||
const initialFunding = (fm.activeScenario?.assumptions.find(a => a.key === 'initial_funding')?.value as number) || 200000
|
||||
|
||||
const tabs: { id: FinTab; label: string }[] = [
|
||||
{ id: 'overview', label: de ? 'Uebersicht' : 'Overview' },
|
||||
{ id: 'overview', label: de ? 'Übersicht' : 'Overview' },
|
||||
{ id: 'guv', label: de ? 'GuV (Jahres)' : 'P&L (Annual)' },
|
||||
{ id: 'cashflow', label: de ? 'Cashflow & Finanzbedarf' : 'Cashflow & Funding' },
|
||||
]
|
||||
@@ -72,9 +88,9 @@ export default function FinancialsSlide({ lang, investorId }: FinancialsSlidePro
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 mb-3">
|
||||
<KPICard
|
||||
label={`ARR 2030`}
|
||||
value={summary ? Math.round(summary.final_arr / 1_000_000 * 10) / 10 : 0}
|
||||
suffix=" Mio."
|
||||
decimals={1}
|
||||
value={kpiArr >= 1_000_000 ? Math.round(kpiArr / 1_000_000 * 10) / 10 : Math.round(kpiArr / 1000)}
|
||||
suffix={kpiArr >= 1_000_000 ? ' Mio.' : 'k'}
|
||||
decimals={kpiArr >= 1_000_000 ? 1 : 0}
|
||||
trend="up"
|
||||
color="#6366f1"
|
||||
delay={0.1}
|
||||
@@ -82,28 +98,29 @@ export default function FinancialsSlide({ lang, investorId }: FinancialsSlidePro
|
||||
/>
|
||||
<KPICard
|
||||
label={de ? 'Kunden 2030' : 'Customers 2030'}
|
||||
value={summary?.final_customers || 0}
|
||||
value={kpiCustomers}
|
||||
trend="up"
|
||||
color="#22c55e"
|
||||
delay={0.15}
|
||||
/>
|
||||
<FadeInView delay={0.2}>
|
||||
<div className="bg-white/[0.06] backdrop-blur-xl border border-white/10 rounded-2xl p-3 text-center">
|
||||
<p className="text-[10px] uppercase tracking-wider text-white/40 mb-1">Break-Even</p>
|
||||
<p className="text-2xl font-bold text-white">{kpiBreakEven || '—'}</p>
|
||||
<span className={`text-xs ${kpiBreakEven && kpiBreakEven <= 2029 ? 'text-emerald-400' : 'text-red-400'}`}>
|
||||
{kpiBreakEven && kpiBreakEven <= 2029 ? '↑' : kpiBreakEven ? '↓' : ''}
|
||||
</span>
|
||||
</div>
|
||||
</FadeInView>
|
||||
<KPICard
|
||||
label="Break-Even"
|
||||
value={summary?.break_even_month || 0}
|
||||
suffix={de ? ' Mo.' : ' mo.'}
|
||||
trend={summary?.break_even_month && summary.break_even_month <= 24 ? 'up' : 'down'}
|
||||
color="#eab308"
|
||||
delay={0.2}
|
||||
subLabel={summary?.break_even_month ? `~${Math.ceil((summary.break_even_month) / 12) + 2025}` : ''}
|
||||
/>
|
||||
<KPICard
|
||||
label="LTV/CAC"
|
||||
value={summary?.final_ltv_cac || 0}
|
||||
suffix="x"
|
||||
label="EBIT 2030"
|
||||
value={fpLast?.ebit ? Math.round(fpLast.ebit / 1_000_000 * 10) / 10 : 0}
|
||||
suffix=" Mio."
|
||||
decimals={1}
|
||||
trend={(summary?.final_ltv_cac || 0) >= 3 ? 'up' : 'down'}
|
||||
trend={(fpLast?.ebit || 0) > 0 ? 'up' : 'down'}
|
||||
color="#a855f7"
|
||||
delay={0.25}
|
||||
subLabel="EUR"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -116,7 +133,7 @@ export default function FinancialsSlide({ lang, investorId }: FinancialsSlidePro
|
||||
className={`px-3 py-1.5 rounded-lg text-xs transition-all
|
||||
${activeTab === tab.id
|
||||
? 'bg-indigo-500/20 text-indigo-300 border border-indigo-500/30'
|
||||
: 'bg-white/[0.04] text-white/40 border border-transparent hover:text-white/60 hover:bg-white/[0.06]'
|
||||
: 'bg-white/[0.04] text-white/40 border border-transparent hover:text-white/60 hover:bg-white/[0.06] animate-[pulse_3s_ease-in-out_infinite]'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
@@ -124,73 +141,98 @@ export default function FinancialsSlide({ lang, investorId }: FinancialsSlidePro
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Main content: 3-column layout */}
|
||||
<div className="grid md:grid-cols-12 gap-3">
|
||||
{/* Left: Charts (8 columns) */}
|
||||
<div className="md:col-span-8 space-y-3">
|
||||
{/* Main content: full width */}
|
||||
<div className="space-y-3">
|
||||
|
||||
{/* TAB: Overview — monatlicher Chart + Waterfall + Unit Economics */}
|
||||
{/* TAB: Overview — annual charts from fp_* */}
|
||||
{activeTab === 'overview' && (
|
||||
<>
|
||||
{/* Revenue vs Costs */}
|
||||
<FadeInView delay={0.1}>
|
||||
<div className="bg-white/[0.05] backdrop-blur-xl border border-white/10 rounded-2xl p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-xs text-white/40">
|
||||
{de ? 'Umsatz vs. Kosten (60 Monate)' : 'Revenue vs. Costs (60 months)'}
|
||||
</p>
|
||||
<div className="flex items-center gap-3 text-[9px]">
|
||||
<span className="flex items-center gap-1"><span className="w-2 h-0.5 bg-indigo-500 inline-block" /> {de ? 'Umsatz' : 'Revenue'}</span>
|
||||
<span className="flex items-center gap-1"><span className="w-2 h-0.5 bg-red-400 inline-block" style={{ borderBottom: '1px dashed' }} /> {de ? 'Kosten' : 'Costs'}</span>
|
||||
<span className="flex items-center gap-1"><span className="w-2 h-0.5 bg-emerald-500 inline-block" /> {de ? 'Kunden' : 'Customers'}</span>
|
||||
</div>
|
||||
<div className="bg-white/[0.05] backdrop-blur-xl border border-white/10 rounded-2xl p-4">
|
||||
<p className="text-xs text-white/40 mb-3">{de ? 'Umsatz vs. Kosten (pro Jahr) · Quelle: Finanzplan' : 'Revenue vs. Costs (per year) · Source: Financial Plan'}</p>
|
||||
<div className="grid grid-cols-5 gap-3 items-end h-40">
|
||||
{[2026,2027,2028,2029,2030].map((y, idx) => {
|
||||
const rev = fpKPIs[`y${y}`]?.revenue || 0
|
||||
const costs = rev - (fpKPIs[`y${y}`]?.ebit || 0)
|
||||
const cust = fpKPIs[`y${y}`]?.customers || 0
|
||||
const maxVal = Math.max(...[2026,2027,2028,2029,2030].map(yr => Math.max(fpKPIs[`y${yr}`]?.revenue || 0, (fpKPIs[`y${yr}`]?.revenue || 0) - (fpKPIs[`y${yr}`]?.ebit || 0))), 1)
|
||||
return (
|
||||
<div key={idx} className="flex flex-col items-center gap-1">
|
||||
<div className="flex items-end gap-1 w-full justify-center" style={{ height: '110px' }}>
|
||||
<div className="w-7 bg-indigo-500/60 rounded-t" style={{ height: `${(rev / maxVal) * 100}px` }}>
|
||||
<div className="text-[10px] text-indigo-300 text-center -mt-4 whitespace-nowrap font-semibold">{rev >= 1000000 ? `${(rev/1000000).toFixed(1)}M` : `${Math.round(rev/1000)}k`}</div>
|
||||
</div>
|
||||
<div className="w-7 bg-red-500/40 rounded-t" style={{ height: `${(costs / maxVal) * 100}px` }}>
|
||||
<div className="text-[10px] text-red-300 text-center -mt-4 whitespace-nowrap font-semibold">{costs >= 1000000 ? `${(costs/1000000).toFixed(1)}M` : `${Math.round(costs/1000)}k`}</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs text-white/50 font-medium">{y}</span>
|
||||
<span className="text-[9px] text-emerald-400/60">{cust} {de ? 'Kd.' : 'cust.'}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="flex justify-center gap-6 mt-2 text-[10px]">
|
||||
<span className="flex items-center gap-1"><span className="w-3 h-2 bg-indigo-500/60 rounded inline-block" /> {de ? 'Umsatz' : 'Revenue'}</span>
|
||||
<span className="flex items-center gap-1"><span className="w-3 h-2 bg-red-500/40 rounded inline-block" /> {de ? 'Kosten' : 'Costs'}</span>
|
||||
</div>
|
||||
<FinancialChart
|
||||
activeResults={activeResults}
|
||||
compareResults={compareResults}
|
||||
compareMode={fm.compareMode}
|
||||
scenarioColors={scenarioColors}
|
||||
lang={lang}
|
||||
/>
|
||||
</div>
|
||||
</FadeInView>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-3">
|
||||
{/* EBIT + Liquidität */}
|
||||
<FadeInView delay={0.2}>
|
||||
<div className="bg-white/[0.05] backdrop-blur-xl border border-white/10 rounded-2xl p-3">
|
||||
<p className="text-xs text-white/40 mb-2">
|
||||
{de ? 'Cash-Flow (Quartal)' : 'Cash Flow (Quarterly)'}
|
||||
</p>
|
||||
{activeResults && <WaterfallChart results={activeResults.results} lang={lang} />}
|
||||
<div className="bg-white/[0.05] backdrop-blur-xl border border-white/10 rounded-2xl p-4">
|
||||
<p className="text-xs text-white/40 mb-3">EBIT & {de ? 'Liquidität' : 'Cash'}</p>
|
||||
<div className="grid grid-cols-5 gap-1 items-end h-28">
|
||||
{[2026,2027,2028,2029,2030].map((y, idx) => {
|
||||
const ebit = fpKPIs[`y${y}`]?.ebit || 0
|
||||
const liq = fpKPIs[`y${y}`]?.liquiditaet || 0
|
||||
const maxAbs = Math.max(...[2026,2027,2028,2029,2030].map(yr => Math.max(Math.abs(fpKPIs[`y${yr}`]?.ebit || 0), Math.abs(fpKPIs[`y${yr}`]?.liquiditaet || 0))), 1)
|
||||
return (
|
||||
<div key={idx} className="flex flex-col items-center gap-0.5">
|
||||
<div className="flex items-end gap-0.5 justify-center" style={{ height: '80px' }}>
|
||||
<div className={`w-5 ${ebit >= 0 ? 'bg-emerald-500/60 rounded-t' : 'bg-red-500/60 rounded-b'}`} style={{ height: `${Math.max(Math.abs(ebit)/maxAbs * 70, 2)}px` }} />
|
||||
<div className={`w-5 ${liq >= 0 ? 'bg-cyan-500/60 rounded-t' : 'bg-red-500/40 rounded-b'}`} style={{ height: `${Math.max(Math.abs(liq)/maxAbs * 70, 2)}px` }} />
|
||||
</div>
|
||||
<span className="text-[9px] text-white/40">{y}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="flex justify-center gap-4 mt-2 text-[9px]">
|
||||
<span className="flex items-center gap-1"><span className="w-2 h-2 bg-emerald-500/60 rounded inline-block" /> EBIT</span>
|
||||
<span className="flex items-center gap-1"><span className="w-2 h-2 bg-cyan-500/60 rounded inline-block" /> {de ? 'Liquidität' : 'Cash'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</FadeInView>
|
||||
|
||||
{/* Key Metrics */}
|
||||
<FadeInView delay={0.25}>
|
||||
<div className="space-y-3">
|
||||
<div className="bg-white/[0.05] backdrop-blur-xl border border-white/10 rounded-2xl p-3 flex justify-center">
|
||||
<RunwayGauge
|
||||
months={lastResult?.runway_months || 0}
|
||||
size={120}
|
||||
label={de ? 'Runway (Monate)' : 'Runway (months)'}
|
||||
/>
|
||||
<div className="bg-white/[0.05] backdrop-blur-xl border border-white/10 rounded-2xl p-4">
|
||||
<p className="text-xs text-white/40 mb-3">Unit Economics (2030)</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{[
|
||||
{ label: 'ACV', value: fpLast?.arpu ? `${fpLast.arpu.toLocaleString('de-DE')} EUR` : '—', color: 'text-indigo-300' },
|
||||
{ label: 'Gross Margin', value: fpLast?.grossMargin ? `${fpLast.grossMargin}%` : '—', color: 'text-emerald-300' },
|
||||
{ label: 'NRR', value: fpLast?.nrr ? `${fpLast.nrr}%` : '—', color: 'text-purple-300' },
|
||||
{ label: 'EBIT Margin', value: fpLast?.ebitMargin ? `${fpLast.ebitMargin}%` : '—', color: 'text-amber-300' },
|
||||
].map((m, idx) => (
|
||||
<div key={idx} className="text-center bg-white/[0.03] rounded-lg p-2">
|
||||
<p className="text-[10px] text-white/40 uppercase tracking-wider">{m.label}</p>
|
||||
<p className={`text-lg font-bold ${m.color}`}>{m.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{lastResult && (
|
||||
<UnitEconomicsCards
|
||||
cac={lastResult.cac_eur}
|
||||
ltv={lastResult.ltv_eur}
|
||||
ltvCacRatio={lastResult.ltv_cac_ratio}
|
||||
grossMargin={lastResult.gross_margin_pct}
|
||||
churnRate={fm.activeScenario?.assumptions.find(a => a.key === 'churn_rate_monthly')?.value as number || 3}
|
||||
lang={lang}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</FadeInView>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* TAB: GuV — Annual P&L Table */}
|
||||
{activeTab === 'guv' && activeResults && (
|
||||
{/* TAB: GuV — from fp_guv */}
|
||||
{activeTab === 'guv' && (
|
||||
<FadeInView delay={0.1}>
|
||||
<div className="bg-white/[0.05] backdrop-blur-xl border border-white/10 rounded-2xl p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
@@ -198,101 +240,79 @@ export default function FinancialsSlide({ lang, investorId }: FinancialsSlidePro
|
||||
{de ? 'Gewinn- und Verlustrechnung (5 Jahre)' : 'Profit & Loss Statement (5 Years)'}
|
||||
</p>
|
||||
<p className="text-[9px] text-white/20">
|
||||
{de ? 'Alle Werte in EUR' : 'All values in EUR'}
|
||||
{de ? 'Alle Werte in EUR · Quelle: Finanzplan' : 'All values in EUR · Source: Financial Plan'}
|
||||
</p>
|
||||
</div>
|
||||
<AnnualPLTable results={activeResults.results} lang={lang} />
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-white/10">
|
||||
<th className="text-left py-2 text-white/40 font-medium"></th>
|
||||
{[2026,2027,2028,2029,2030].map(y => <th key={y} className="text-right py-2 px-2 text-white/40 font-medium">{y}</th>)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[
|
||||
{ label: de ? 'Umsatzerlöse' : 'Revenue', key: 'revenue', bold: true },
|
||||
{ label: de ? 'Personalkosten' : 'Personnel', key: 'personal', bold: false },
|
||||
{ label: 'EBIT', key: 'ebit', bold: true },
|
||||
{ label: de ? 'Steuern' : 'Taxes', key: 'steuern', bold: false },
|
||||
{ label: de ? 'Jahresüberschuss' : 'Net Income', key: 'netIncome', bold: true },
|
||||
].map((row, idx) => (
|
||||
<tr key={idx} className={`border-b border-white/[0.03] ${row.bold ? 'bg-white/[0.02]' : ''}`}>
|
||||
<td className={`py-1.5 ${row.bold ? 'font-bold text-white/70' : 'text-white/50'}`}>{row.label}</td>
|
||||
{[2026,2027,2028,2029,2030].map(y => {
|
||||
const v = fpKPIs[`y${y}`]?.[row.key as keyof typeof fpKPIs['y2026']] || 0
|
||||
const num = typeof v === 'number' ? v : 0
|
||||
return (
|
||||
<td key={y} className={`text-right py-1.5 px-2 font-mono ${num < 0 ? 'text-red-400' : row.bold ? 'text-white/70 font-bold' : 'text-white/50'}`}>
|
||||
{num === 0 ? '—' : (num >= 1000000 || num <= -1000000) ? `${(num/1000000).toFixed(1)}M` : `${Math.round(num/1000)}k`}
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</FadeInView>
|
||||
)}
|
||||
|
||||
{/* TAB: Cashflow & Finanzbedarf */}
|
||||
{activeTab === 'cashflow' && activeResults && (
|
||||
{/* TAB: Cashflow — from fp_* */}
|
||||
{activeTab === 'cashflow' && (
|
||||
<FadeInView delay={0.1}>
|
||||
<div className="bg-white/[0.05] backdrop-blur-xl border border-white/10 rounded-2xl p-4">
|
||||
<p className="text-xs text-white/40 mb-3">
|
||||
{de ? 'Jaehrlicher Cashflow & Finanzbedarf' : 'Annual Cash Flow & Funding Requirements'}
|
||||
{de ? 'Liquidität & Cashflow (5 Jahre) · Quelle: Finanzplan' : 'Liquidity & Cash Flow (5 Years) · Source: Financial Plan'}
|
||||
</p>
|
||||
<AnnualCashflowChart
|
||||
results={activeResults.results}
|
||||
initialFunding={initialFunding}
|
||||
lang={lang}
|
||||
/>
|
||||
<div className="grid grid-cols-5 gap-2 items-end h-48">
|
||||
{[2026,2027,2028,2029,2030].map((y, idx) => {
|
||||
const liq = fpKPIs[`y${y}`]?.liquiditaet || 0
|
||||
const ebit = fpKPIs[`y${y}`]?.ebit || 0
|
||||
const maxAbs = Math.max(...[2026,2027,2028,2029,2030].map(yr => Math.abs(fpKPIs[`y${yr}`]?.liquiditaet || 0)), 1)
|
||||
const h = Math.abs(liq) / maxAbs * 140
|
||||
return (
|
||||
<div key={idx} className="flex flex-col items-center">
|
||||
<div className="text-[8px] text-white/40 mb-1">{liq >= 1000000 ? `${(liq/1000000).toFixed(1)}M` : liq <= -1000000 ? `${(liq/1000000).toFixed(1)}M` : `${Math.round(liq/1000)}k`}</div>
|
||||
<div className="w-12 flex flex-col justify-end" style={{ height: '150px' }}>
|
||||
<div className={`${liq >= 0 ? 'bg-emerald-500/60 rounded-t' : 'bg-red-500/60 rounded-b'} w-full`} style={{ height: `${Math.max(h, 4)}px` }} />
|
||||
</div>
|
||||
<span className="text-[10px] text-white/40 mt-1">{y}</span>
|
||||
<span className={`text-[8px] mt-0.5 ${ebit >= 0 ? 'text-emerald-400/60' : 'text-red-400/60'}`}>
|
||||
EBIT: {ebit >= 1000000 ? `${(ebit/1000000).toFixed(1)}M` : `${Math.round(ebit/1000)}k`}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="flex justify-center gap-6 mt-3 text-[10px]">
|
||||
<span className="flex items-center gap-1"><span className="w-3 h-2 bg-emerald-500/60 rounded inline-block" /> {de ? 'Liquidität (positiv)' : 'Cash (positive)'}</span>
|
||||
<span className="flex items-center gap-1"><span className="w-3 h-2 bg-red-500/60 rounded inline-block" /> {de ? 'Liquidität (negativ)' : 'Cash (negative)'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</FadeInView>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Controls (4 columns) */}
|
||||
<div className="md:col-span-4 space-y-3">
|
||||
{/* Scenario Switcher */}
|
||||
<FadeInView delay={0.15}>
|
||||
<div className="bg-white/[0.05] backdrop-blur-xl border border-white/10 rounded-2xl p-3">
|
||||
<ScenarioSwitcher
|
||||
scenarios={fm.scenarios}
|
||||
activeId={fm.activeScenarioId}
|
||||
compareMode={fm.compareMode}
|
||||
onSelect={(id) => {
|
||||
fm.setActiveScenarioId(id)
|
||||
}}
|
||||
onToggleCompare={() => {
|
||||
if (!fm.compareMode) {
|
||||
fm.computeAll()
|
||||
}
|
||||
fm.setCompareMode(!fm.compareMode)
|
||||
}}
|
||||
lang={lang}
|
||||
/>
|
||||
</div>
|
||||
</FadeInView>
|
||||
|
||||
{/* Assumption Sliders */}
|
||||
<FadeInView delay={0.2}>
|
||||
<div className="bg-white/[0.05] backdrop-blur-xl border border-white/10 rounded-2xl p-3">
|
||||
<p className="text-[10px] text-white/40 uppercase tracking-wider mb-2">
|
||||
{i.financials.adjustAssumptions}
|
||||
</p>
|
||||
{fm.activeScenario && (
|
||||
<FinancialSliders
|
||||
assumptions={fm.activeScenario.assumptions}
|
||||
onAssumptionChange={(key, value) => {
|
||||
if (fm.activeScenarioId) {
|
||||
fm.updateAssumption(fm.activeScenarioId, key, value)
|
||||
}
|
||||
}}
|
||||
lang={lang}
|
||||
/>
|
||||
)}
|
||||
{fm.computing && (
|
||||
<div className="flex items-center gap-2 mt-2 text-[10px] text-indigo-400">
|
||||
<div className="w-3 h-3 border border-indigo-400 border-t-transparent rounded-full animate-spin" />
|
||||
{de ? 'Berechne...' : 'Computing...'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Snapshot status + reset */}
|
||||
{investorId && (
|
||||
<div className="flex items-center justify-between mt-2 pt-2 border-t border-white/5">
|
||||
<span className="text-[9px] text-white/30">
|
||||
{fm.snapshotStatus === 'saving' && (de ? 'Speichere...' : 'Saving...')}
|
||||
{fm.snapshotStatus === 'saved' && (de ? 'Ihre Aenderungen gespeichert' : 'Your changes saved')}
|
||||
{fm.snapshotStatus === 'restored' && (de ? 'Ihre Werte geladen' : 'Your values restored')}
|
||||
{fm.snapshotStatus === 'default' && (de ? 'Standardwerte' : 'Defaults')}
|
||||
</span>
|
||||
{fm.snapshotStatus !== 'default' && (
|
||||
<button
|
||||
onClick={() => fm.activeScenarioId && fm.resetToDefaults(fm.activeScenarioId)}
|
||||
className="text-[9px] text-white/40 hover:text-white/70 transition-colors"
|
||||
>
|
||||
{de ? 'Zuruecksetzen' : 'Reset to defaults'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FadeInView>
|
||||
</div>
|
||||
</div>
|
||||
<ProjectionFooter lang={lang} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,58 +9,31 @@ import { Target, Users, Handshake, Megaphone, Building2, GraduationCap } from 'l
|
||||
|
||||
interface GTMSlideProps {
|
||||
lang: Language
|
||||
isWandeldarlehen?: boolean
|
||||
}
|
||||
|
||||
export default function GTMSlide({ lang }: GTMSlideProps) {
|
||||
export default function GTMSlide({ lang, isWandeldarlehen }: GTMSlideProps) {
|
||||
const i = t(lang)
|
||||
const de = lang === 'de'
|
||||
|
||||
const phases = [
|
||||
{
|
||||
phase: de ? 'Phase 1: Pilot (2026)' : 'Phase 1: Pilot (2026)',
|
||||
color: 'border-indigo-500/30 bg-indigo-500/5',
|
||||
textColor: 'text-indigo-400',
|
||||
items: [
|
||||
de ? 'Direktvertrieb an 5-20 KMU in DACH' : 'Direct sales to 5-20 SMEs in DACH',
|
||||
de ? 'Fokus: Gesundheitswesen, Finanzdienstleister, Rechtsanwaelte' : 'Focus: Healthcare, Financial Services, Law Firms',
|
||||
de ? 'Persoenliches Onboarding, White-Glove-Service' : 'Personal onboarding, white-glove service',
|
||||
de ? 'Case Studies und Referenzkunden aufbauen' : 'Build case studies and reference customers',
|
||||
],
|
||||
},
|
||||
{
|
||||
phase: de ? 'Phase 2: Skalierung (2027)' : 'Phase 2: Scale (2027)',
|
||||
color: 'border-purple-500/30 bg-purple-500/5',
|
||||
textColor: 'text-purple-400',
|
||||
items: [
|
||||
de ? 'Channel-Partnerschaften mit IT-Systemhaeusern' : 'Channel partnerships with IT system integrators',
|
||||
de ? 'IHK- und Handwerkskammer-Kooperationen' : 'Chamber of Commerce & Industry partnerships',
|
||||
de ? 'Content Marketing: Compliance-Webinare, Whitepaper' : 'Content marketing: Compliance webinars, whitepapers',
|
||||
de ? 'Zielkunden: 50-200 in regulierten Branchen' : 'Target: 50-200 customers in regulated industries',
|
||||
],
|
||||
},
|
||||
{
|
||||
phase: de ? 'Phase 3: Expansion (2028+)' : 'Phase 3: Expansion (2028+)',
|
||||
color: 'border-emerald-500/30 bg-emerald-500/5',
|
||||
textColor: 'text-emerald-400',
|
||||
items: [
|
||||
de ? 'Cloud-Tier fuer groessere Unternehmen (50-500 MA)' : 'Cloud tier for larger companies (50-500 employees)',
|
||||
de ? 'EU-Expansion: Oesterreich, Schweiz, Benelux, Nordics' : 'EU expansion: Austria, Switzerland, Benelux, Nordics',
|
||||
de ? 'OEM/Whitelabel fuer Steuerberater und Wirtschaftspruefer' : 'OEM/whitelabel for tax advisors and auditors',
|
||||
de ? 'Self-Service-Onboarding und PLG-Motion' : 'Self-service onboarding and PLG motion',
|
||||
],
|
||||
},
|
||||
const channelsLean = [
|
||||
{ icon: Target, label: de ? 'Gründer-Vertrieb' : 'Founder Sales', pct: '60%', desc: de ? 'Persönliches Netzwerk + Beratung' : 'Personal network + consulting' },
|
||||
{ icon: Megaphone, label: de ? 'Content & SEO' : 'Content & SEO', pct: '25%', desc: de ? 'Webinare, Whitepaper, Fachbeiträge' : 'Webinars, whitepapers, articles' },
|
||||
{ icon: Users, label: de ? 'Empfehlungen' : 'Referrals', pct: '15%', desc: de ? 'Zufriedene Pilotkunden' : 'Satisfied pilot customers' },
|
||||
]
|
||||
|
||||
const channels = [
|
||||
const channels1M = [
|
||||
{ icon: Target, label: de ? 'Direktvertrieb' : 'Direct Sales', pct: '40%', desc: de ? 'Outbound + Inbound, 2 AEs ab 2027' : 'Outbound + Inbound, 2 AEs from 2027' },
|
||||
{ icon: Handshake, label: de ? 'Channel-Partner' : 'Channel Partners', pct: '30%', desc: de ? 'IT-Haendler, Systemhaeuser, MSPs' : 'IT resellers, system integrators, MSPs' },
|
||||
{ icon: Handshake, label: de ? 'Channel-Partner' : 'Channel Partners', pct: '30%', desc: de ? 'IT-Händler, Systemhäuser, MSPs' : 'IT resellers, system integrators, MSPs' },
|
||||
{ icon: Megaphone, label: de ? 'Content & Events' : 'Content & Events', pct: '20%', desc: de ? 'Webinare, Messen (it-sa), SEO' : 'Webinars, trade shows (it-sa), SEO' },
|
||||
{ icon: Users, label: de ? 'Empfehlungen' : 'Referrals', pct: '10%', desc: de ? 'Bestandskunden-Empfehlungsprogramm' : 'Customer referral program' },
|
||||
]
|
||||
|
||||
const channels = isWandeldarlehen ? channelsLean : channels1M
|
||||
|
||||
const idealCustomer = [
|
||||
{ icon: Building2, label: de ? '10-250 Mitarbeiter' : '10-250 Employees' },
|
||||
{ icon: GraduationCap, label: de ? 'Regulierte Branche (Gesundheit, Finanzen, Energie, KRITIS)' : 'Regulated Industry (Healthcare, Finance, Energy, Critical Infrastructure)' },
|
||||
{ icon: GraduationCap, label: de ? 'Produzierende Industrie (Maschinenbau, Automotive, Elektro, Chemie)' : 'Manufacturing Industry (Machinery, Automotive, Electrical, Chemicals)' },
|
||||
{ icon: Target, label: de ? 'Kein interner Compliance-Officer oder DSB' : 'No Internal Compliance Officer or DPO' },
|
||||
]
|
||||
|
||||
@@ -96,25 +69,6 @@ export default function GTMSlide({ lang }: GTMSlideProps) {
|
||||
</GlassCard>
|
||||
</FadeInView>
|
||||
|
||||
{/* Phases */}
|
||||
<div className="grid md:grid-cols-3 gap-4 mb-6">
|
||||
{phases.map((phase, idx) => (
|
||||
<FadeInView key={idx} delay={0.2 + idx * 0.1}>
|
||||
<div className={`border rounded-xl p-4 h-full ${phase.color}`}>
|
||||
<p className={`text-sm font-bold ${phase.textColor} mb-3`}>{phase.phase}</p>
|
||||
<ul className="space-y-2">
|
||||
{phase.items.map((item, iidx) => (
|
||||
<li key={iidx} className="flex items-start gap-2 text-xs text-white/60">
|
||||
<span className={`w-1 h-1 rounded-full mt-1.5 ${phase.textColor} bg-current`} />
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</FadeInView>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Channel Mix */}
|
||||
<FadeInView delay={0.5}>
|
||||
<GlassCard hover={false} className="p-4">
|
||||
|
||||
@@ -40,14 +40,18 @@ export default function GlossarySlide({ lang }: GlossarySlideProps) {
|
||||
],
|
||||
},
|
||||
{
|
||||
title: de ? 'EU-Regulierungen' : 'EU Regulations',
|
||||
title: de ? 'EU-Regulierungen & Gesetze' : 'EU Regulations & Laws',
|
||||
color: 'text-cyan-400',
|
||||
terms: [
|
||||
{ abbr: 'AI Act', full: de ? 'KI-Verordnung (EU) 2024/1689' : 'AI Regulation (EU) 2024/1689', desc: de ? 'Weltweit erste KI-Regulierung, Risikoklassen für KI-Systeme' : 'World\'s first AI regulation, risk classes for AI systems' },
|
||||
{ abbr: 'CRA', full: 'Cyber Resilience Act', desc: de ? 'Cybersicherheit für Produkte mit digitalen Elementen, SBOM-Pflicht' : 'Cybersecurity for products with digital elements, SBOM mandatory' },
|
||||
{ abbr: 'NIS2', full: 'Network and Information Security Directive 2', desc: de ? 'Cybersicherheits-Richtlinie, 30.000+ Unternehmen in DE betroffen' : 'Cybersecurity directive, 30,000+ companies in DE affected' },
|
||||
{ abbr: 'MVO', full: de ? 'Maschinenverordnung (EU) 2023/1230' : 'Machinery Regulation (EU) 2023/1230', desc: de ? 'CE-Kennzeichnung inkl. Cybersicherheit ab Jan 2027' : 'CE marking incl. cybersecurity from Jan 2027' },
|
||||
{ abbr: 'FISA 702', full: 'Foreign Intelligence Surveillance Act, Section 702', desc: de ? 'US-Überwachungsgesetz — erlaubt Zugriff auf Daten von Nicht-US-Personen' : 'US surveillance law — allows access to data of non-US persons' },
|
||||
{ abbr: 'Cloud Act', full: 'Clarifying Lawful Overseas Use of Data Act', desc: de ? 'US-Gesetz — extraterritorialer Datenzugriff auf US-Anbieter' : 'US law — extraterritorial data access to US providers' },
|
||||
{ abbr: 'BDSG', full: de ? 'Bundesdatenschutzgesetz' : 'Federal Data Protection Act', desc: de ? 'Deutsche Ergänzung zur DSGVO' : 'German supplement to GDPR' },
|
||||
{ abbr: 'TISAX', full: 'Trusted Information Security Assessment Exchange', desc: de ? 'Informationssicherheits-Standard der Automobilindustrie' : 'Information security standard for automotive industry' },
|
||||
{ abbr: 'BSI', full: de ? 'Bundesamt für Sicherheit in der Informationstechnik' : 'Federal Office for Information Security', desc: de ? 'Deutsche Cyber-Sicherheitsbehörde' : 'German cybersecurity authority' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -64,6 +68,22 @@ export default function GlossarySlide({ lang }: GlossarySlideProps) {
|
||||
{ abbr: 'ROI', full: 'Return on Investment', desc: de ? 'Rendite auf die Investition' : 'Return on investment' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: de ? 'Technologie & Plattform' : 'Technology & Platform',
|
||||
color: 'text-amber-400',
|
||||
terms: [
|
||||
{ abbr: 'RAG', full: 'Retrieval Augmented Generation', desc: de ? 'KI-Methode: Wissenssuche + Textgenerierung kombiniert' : 'AI method: knowledge retrieval + text generation combined' },
|
||||
{ abbr: 'LLM', full: 'Large Language Model', desc: de ? 'Großes Sprachmodell (z.B. GPT, Claude, Qwen)' : 'Large language model (e.g. GPT, Claude, Qwen)' },
|
||||
{ abbr: 'UCCA', full: 'Use-Case Compliance Assessment', desc: de ? 'Automatische Bewertung von KI-Anwendungsfällen nach AI Act' : 'Automatic assessment of AI use cases per AI Act' },
|
||||
{ abbr: 'FRIA', full: 'Fundamental Rights Impact Assessment', desc: de ? 'Grundrechte-Folgenabschätzung nach Art. 27 AI Act' : 'Fundamental rights impact assessment per Art. 27 AI Act' },
|
||||
{ abbr: 'SDK', full: 'Software Development Kit', desc: de ? 'Entwicklungspaket zur Integration in Kundensysteme' : 'Development kit for integration into customer systems' },
|
||||
{ abbr: 'OWASP', full: 'Open Web Application Security Project', desc: de ? 'Open-Source-Sicherheitsstandards für Webanwendungen' : 'Open-source security standards for web applications' },
|
||||
{ abbr: 'NIST', full: 'National Institute of Standards and Technology', desc: de ? 'US-Behörde für Technologiestandards (auch international anerkannt)' : 'US standards body (internationally recognized)' },
|
||||
{ abbr: 'ENISA', full: 'European Union Agency for Cybersecurity', desc: de ? 'EU-Agentur für Cybersicherheit' : 'EU Agency for Cybersecurity' },
|
||||
{ abbr: 'CE', full: de ? 'Conformité Européenne' : 'Conformité Européenne', desc: de ? 'EU-Konformitätskennzeichnung für Produkte' : 'EU conformity marking for products' },
|
||||
{ abbr: 'RFQ', full: 'Request for Quotation', desc: de ? 'Kundenanfrage / Angebotsanforderung' : 'Customer request for quotation' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
@@ -81,11 +101,11 @@ export default function GlossarySlide({ lang }: GlossarySlideProps) {
|
||||
<h3 className={`text-xs font-bold ${cat.color} uppercase tracking-wider mb-3`}>{cat.title}</h3>
|
||||
<div className="space-y-2">
|
||||
{cat.terms.map((term, i) => (
|
||||
<div key={i} className="flex gap-2">
|
||||
<div key={i} className="flex gap-2 items-baseline">
|
||||
<span className={`text-xs font-bold ${cat.color} min-w-[65px] shrink-0`}>{term.abbr}</span>
|
||||
<div>
|
||||
<span className="text-xs text-white/70">{term.full}</span>
|
||||
<span className="text-[10px] text-white/40 ml-1">— {term.desc}</span>
|
||||
<span className="text-xs text-white/40 ml-1">— {term.desc}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -27,8 +27,8 @@ export default function HowItWorksSlide({ lang }: HowItWorksSlideProps) {
|
||||
</FadeInView>
|
||||
|
||||
<div className="relative max-w-4xl mx-auto">
|
||||
{/* Connection Line */}
|
||||
<div className="absolute left-8 top-12 bottom-12 w-px bg-gradient-to-b from-blue-500 via-purple-500 to-green-500 hidden md:block" />
|
||||
{/* Connection Line — behind icons (z-0), icons have z-10 with opaque bg */}
|
||||
<div className="absolute left-8 top-20 bottom-20 w-px bg-gradient-to-b from-blue-500/40 via-purple-500/40 to-green-500/40 hidden md:block z-0" />
|
||||
|
||||
<div className="space-y-8">
|
||||
{i.howItWorks.steps.map((step, idx) => {
|
||||
@@ -42,7 +42,7 @@ export default function HowItWorksSlide({ lang }: HowItWorksSlideProps) {
|
||||
className="flex items-start gap-6 relative"
|
||||
>
|
||||
<div className={`
|
||||
w-16 h-16 rounded-2xl bg-white/[0.06] border border-white/10
|
||||
w-16 h-16 rounded-2xl bg-[#0c0c1d] border border-white/10
|
||||
flex items-center justify-center shrink-0 relative z-10
|
||||
${stepColors[idx]}
|
||||
`}>
|
||||
|
||||
@@ -57,8 +57,8 @@ export default function IntroPresenterSlide({ lang, onStartPresenter, isPresenti
|
||||
</h1>
|
||||
<p className="text-lg text-white/60 max-w-lg mx-auto mb-8">
|
||||
{isDE
|
||||
? 'Ihr persönlicher KI-Guide durch das BreakPilot ComplAI Pitch Deck. 15 Minuten, alle Fakten, jederzeit unterbrechbar.'
|
||||
: 'Your personal AI guide through the BreakPilot ComplAI pitch deck. 15 minutes, all facts, interruptible at any time.'}
|
||||
? 'Ihr persönlicher KI-Guide durch das BreakPilot COMPLAI Pitch Deck. 15 Minuten, alle Fakten, jederzeit unterbrechbar.'
|
||||
: 'Your personal AI guide through the BreakPilot COMPLAI pitch deck. 15 minutes, all facts, interruptible at any time.'}
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
|
||||
@@ -22,21 +22,21 @@ interface MarketSourceInfo {
|
||||
excerpt_en: string
|
||||
}
|
||||
|
||||
// Quellenangaben fuer die Marktzahlen
|
||||
// Quellenangaben für die Marktzahlen
|
||||
const marketSources: Record<string, MarketSourceInfo[]> = {
|
||||
TAM: [
|
||||
{
|
||||
name: 'Bottom-Up-Validierung: Echte Umsatzdaten der Top-10 Compliance-Anbieter',
|
||||
url: 'https://sacra.com/c/vanta/',
|
||||
date: '2025-2026',
|
||||
excerpt_de: 'Die Top-10 Compliance-Automation-Anbieter erzielen zusammen ~$1,13 Mrd. Umsatz (Vanta $220M, OneTrust $500M, Drata $100M, Usercentrics $117M, Securiti $76M, DataGuard €52M, Sprinto $38M, heyData €15M, Caralegal €5.8M, Proliance €3.9M). Mit 50+ weiteren Anbietern liegt der Gesamtmarkt bei ~$1,6-2 Mrd. — aktuell nur ~20% des adressierbaren Volumens (Gartner: 80% der Unternehmen managen Compliance noch manuell). Inkl. DevSecOps fuer Manufacturing (~$3,5 Mrd.) ergibt sich ein TAM von $8-12 Mrd.',
|
||||
excerpt_de: 'Die Top-10 Compliance-Automation-Anbieter erzielen zusammen ~$1,13 Mrd. Umsatz (Vanta $220M, OneTrust $500M, Drata $100M, Usercentrics $117M, Securiti $76M, DataGuard €52M, Sprinto $38M, heyData €15M, Caralegal €5.8M, Proliance €3.9M). Mit 50+ weiteren Anbietern liegt der Gesamtmarkt bei ~$1,6-2 Mrd. — aktuell nur ~20% des adressierbaren Volumens (Gartner: 80% der Unternehmen managen Compliance noch manuell). Inkl. DevSecOps für Manufacturing (~$3,5 Mrd.) ergibt sich ein TAM von $8-12 Mrd.',
|
||||
excerpt_en: 'The top 10 compliance automation providers generate ~$1.13B combined revenue (Vanta $220M, OneTrust $500M, Drata $100M, Usercentrics $117M, Securiti $76M, DataGuard €52M, Sprinto $38M, heyData €15M, Caralegal €5.8M, Proliance €3.9M). With 50+ additional vendors, the total market is ~$1.6-2B — currently only ~20% of addressable volume (Gartner: 80% manage compliance manually). Incl. DevSecOps for manufacturing (~$3.5B), the TAM is $8-12B.',
|
||||
},
|
||||
{
|
||||
name: 'Grand View Research — GRC Market Report 2024',
|
||||
url: 'https://www.grandviewresearch.com/industry-analysis/governance-risk-management-compliance-market',
|
||||
date: '2024',
|
||||
excerpt_de: 'Der globale GRC-Software-Markt wurde 2023 auf 11,8 Mrd. USD bewertet, CAGR 13,8%. Die Compliance-Automation-Welle (Vanta, Drata) zeigt 30-45% Wachstum p.a. — deutlich ueber dem Branchendurchschnitt.',
|
||||
excerpt_de: 'Der globale GRC-Software-Markt wurde 2023 auf 11,8 Mrd. USD bewertet, CAGR 13,8%. Die Compliance-Automation-Welle (Vanta, Drata) zeigt 30-45% Wachstum p.a. — deutlich über dem Branchendurchschnitt.',
|
||||
excerpt_en: 'The global GRC software market was valued at USD 11.8B in 2023, CAGR 13.8%. The compliance automation wave (Vanta, Drata) shows 30-45% p.a. growth — well above industry average.',
|
||||
},
|
||||
],
|
||||
@@ -45,7 +45,7 @@ const marketSources: Record<string, MarketSourceInfo[]> = {
|
||||
name: 'Bottom-Up: DACH Compliance-Anbieter + NIS2/CRA/AI-Act Expansion',
|
||||
url: 'https://www.vdma.org/statistics',
|
||||
date: '2025-2026',
|
||||
excerpt_de: 'DACH-Compliance-Umsaetze heute: DataGuard €52M + heyData €15M + Proliance €3.9M + Caralegal €5.8M + OneTrust DACH ~€30M + Secjur/andere ~€10M = ~€120M (nur DSGVO-Compliance). NIS2 erweitert die Regulierung auf 30.000+ Unternehmen (bisher 4.500). CRA und AI Act schaffen voellig neue Pflichten fuer Maschinenbauer. DACH-DevSecOps-Markt: +€300-400M. Gesamtes SAM fuer Compliance + Code-Security in DACH Manufacturing: €850M-1,2 Mrd.',
|
||||
excerpt_de: 'DACH-Compliance-Umsaetze heute: DataGuard €52M + heyData €15M + Proliance €3.9M + Caralegal €5.8M + OneTrust DACH ~€30M + Secjur/andere ~€10M = ~€120M (nur DSGVO-Compliance). NIS2 erweitert die Regulierung auf 30.000+ Unternehmen (bisher 4.500). CRA und AI Act schaffen voellig neue Pflichten für Maschinenbauer. DACH-DevSecOps-Markt: +€300-400M. Gesamtes SAM für Compliance + Code-Security in DACH Manufacturing: €850M-1,2 Mrd.',
|
||||
excerpt_en: 'DACH compliance revenues today: DataGuard €52M + heyData €15M + Proliance €3.9M + Caralegal €5.8M + OneTrust DACH ~€30M + Secjur/others ~€10M = ~€120M (GDPR compliance only). NIS2 expands regulation to 30,000+ companies (from 4,500). CRA and AI Act create entirely new obligations for manufacturers. DACH DevSecOps market: +€300-400M. Total SAM for compliance + code security in DACH manufacturing: €850M-1.2B.',
|
||||
},
|
||||
],
|
||||
@@ -54,7 +54,7 @@ const marketSources: Record<string, MarketSourceInfo[]> = {
|
||||
name: 'VDMA Mitgliederstatistik + Wettbewerbs-Benchmarks',
|
||||
url: 'https://www.vdma.org/mitglieder',
|
||||
date: '2025-2026',
|
||||
excerpt_de: 'DACH-weit ca. 5.000 Maschinenbauer mit Eigenentwicklung (VDMA). Bei 10% Marktdurchdringung (~500 Unternehmen) und €14.400/Jahr ARPU (Blended Avg.) ergibt sich ein SOM von €7,2 Mio. Zum Vergleich: Proliance mit 65 Mitarbeitern erreicht €3,9M, heyData mit 58 MA bereits €15M. Mit KI-Automatisierung ist eine hoehere Durchdringung bei niedrigerer Personalintensitaet moeglich.',
|
||||
excerpt_de: 'DACH-weit ca. 5.000 Maschinenbauer mit Eigenentwicklung (VDMA). Bei 10% Marktdurchdringung (~500 Unternehmen) und €14.400/Jahr ARPU (Blended Avg.) ergibt sich ein SOM von €7,2 Mio. Zum Vergleich: Proliance mit 65 Mitarbeitern erreicht €3,9M, heyData mit 58 MA bereits €15M. Mit KI-Automatisierung ist eine höhere Durchdringung bei niedrigerer Personalintensität möglich.',
|
||||
excerpt_en: 'Approx. 5,000 DACH machine manufacturers with in-house dev (VDMA). At 10% penetration (~500 companies) and €14,400/yr ARPU (blended avg.), SOM is €7.2M. For comparison: Proliance with 65 employees achieves €3.9M, heyData with 58 employees already €15M. AI automation enables higher penetration with lower headcount intensity.',
|
||||
},
|
||||
],
|
||||
@@ -84,14 +84,14 @@ const pentestMarketSources: Record<string, MarketSourceInfo[]> = {
|
||||
name: 'MarketsAndMarkets — Application Security Testing Market 2025',
|
||||
url: 'https://www.marketsandmarkets.com/Market-Reports/application-security-testing-market-150735030.html',
|
||||
date: '2025',
|
||||
excerpt_de: 'Der globale AST-Markt (SAST, DAST, IAST, SCA) wird auf $8,5 Mrd. (2025) geschaetzt und soll bis 2030 auf $19,5 Mrd. wachsen (CAGR 18,2%). Hinzu kommt der Pentesting-Markt ($2,7 Mrd.) und der Compliance-Convergence-Anteil ($1,8 Mrd.). Gesamt-TAM fuer integriertes AppSec + Compliance: ~$13 Mrd.',
|
||||
excerpt_de: 'Der globale AST-Markt (SAST, DAST, IAST, SCA) wird auf $8,5 Mrd. (2025) geschätzt und soll bis 2030 auf $19,5 Mrd. wachsen (CAGR 18,2%). Hinzu kommt der Pentesting-Markt ($2,7 Mrd.) und der Compliance-Convergence-Anteil ($1,8 Mrd.). Gesamt-TAM für integriertes AppSec + Compliance: ~$13 Mrd.',
|
||||
excerpt_en: 'The global AST market (SAST, DAST, IAST, SCA) is estimated at $8.5B (2025), projected to reach $19.5B by 2030 (CAGR 18.2%). Adding the pentesting market ($2.7B) and compliance convergence share ($1.8B), total TAM for integrated AppSec + compliance: ~$13B.',
|
||||
},
|
||||
{
|
||||
name: 'Gartner — Magic Quadrant for Application Security Testing 2024',
|
||||
url: 'https://www.gartner.com/reviews/market/application-security-testing',
|
||||
date: '2024',
|
||||
excerpt_de: 'Gartner bestaetigt den Trend zur Konvergenz von AppSec und Compliance. Fuehrende Anbieter (Snyk, Veracode, Checkmarx) erreichen zusammen >$850M Umsatz. Der Markt waechst mit 17-20% p.a., getrieben durch regulatorische Anforderungen (CRA, NIS2) und AI-getriebene Entwicklung.',
|
||||
excerpt_de: 'Gartner bestätigt den Trend zur Konvergenz von AppSec und Compliance. Führende Anbieter (Snyk, Veracode, Checkmarx) erreichen zusammen >$850M Umsatz. Der Markt wächst mit 17-20% p.a., getrieben durch regulatorische Anforderungen (CRA, NIS2) und AI-getriebene Entwicklung.',
|
||||
excerpt_en: 'Gartner confirms the AppSec-compliance convergence trend. Leading vendors (Snyk, Veracode, Checkmarx) generate >$850M combined revenue. The market grows at 17-20% p.a., driven by regulatory requirements (CRA, NIS2) and AI-driven development.',
|
||||
},
|
||||
],
|
||||
@@ -100,7 +100,7 @@ const pentestMarketSources: Record<string, MarketSourceInfo[]> = {
|
||||
name: 'Bottom-Up: DACH AppSec + Manufacturing Pentesting',
|
||||
url: 'https://www.bitkom.org/Marktdaten/ITK-Konjunktur/IT-Markt-Deutschland',
|
||||
date: '2025-2026',
|
||||
excerpt_de: 'DACH IT-Security-Markt: €8,2 Mrd. (Bitkom 2025). AppSec-Anteil: ~15% = €1,2 Mrd. Davon Pentesting/DAST/SAST fuer produzierende Industrie: ~€400M. CRA-Pflicht fuer Maschinenbauer erzeugt neue Nachfrage: geschaetzt +€200M bis 2028. SAM fuer integriertes AppSec + Compliance im DACH-Manufacturing: ~€1,6 Mrd.',
|
||||
excerpt_de: 'DACH IT-Security-Markt: €8,2 Mrd. (Bitkom 2025). AppSec-Anteil: ~15% = €1,2 Mrd. Davon Pentesting/DAST/SAST für produzierende Industrie: ~€400M. CRA-Pflicht für Maschinenbauer erzeugt neue Nachfrage: geschätzt +€200M bis 2028. SAM für integriertes AppSec + Compliance im DACH-Manufacturing: ~€1,6 Mrd.',
|
||||
excerpt_en: 'DACH IT security market: €8.2B (Bitkom 2025). AppSec share: ~15% = €1.2B. Pentesting/DAST/SAST for manufacturing: ~€400M. CRA obligation for manufacturers creates new demand: est. +€200M by 2028. SAM for integrated AppSec + compliance in DACH manufacturing: ~€1.6B.',
|
||||
},
|
||||
],
|
||||
@@ -225,7 +225,7 @@ export default function MarketSlide({ lang, market }: MarketSlideProps) {
|
||||
className={`px-4 py-1.5 rounded-full text-xs font-medium transition-all ${
|
||||
marketView === 'compliance'
|
||||
? 'bg-indigo-500/20 text-indigo-300 border border-indigo-500/30'
|
||||
: 'bg-white/[0.04] text-white/40 border border-white/5 hover:bg-white/[0.08]'
|
||||
: 'bg-white/[0.04] text-white/40 border border-white/5 hover:bg-white/[0.08] animate-[pulse_3s_ease-in-out_infinite]'
|
||||
}`}
|
||||
>
|
||||
{lang === 'de' ? 'Compliance & Code-Security' : 'Compliance & Code Security'}
|
||||
@@ -235,7 +235,7 @@ export default function MarketSlide({ lang, market }: MarketSlideProps) {
|
||||
className={`px-4 py-1.5 rounded-full text-xs font-medium transition-all flex items-center gap-1.5 ${
|
||||
marketView === 'pentesting'
|
||||
? 'bg-red-500/20 text-red-300 border border-red-500/30'
|
||||
: 'bg-white/[0.04] text-white/40 border border-white/5 hover:bg-white/[0.08]'
|
||||
: 'bg-white/[0.04] text-white/40 border border-white/5 hover:bg-white/[0.08] animate-[pulse_3s_ease-in-out_infinite]'
|
||||
}`}
|
||||
>
|
||||
<Shield className="w-3 h-3" />
|
||||
@@ -304,7 +304,7 @@ export default function MarketSlide({ lang, market }: MarketSlideProps) {
|
||||
</div>
|
||||
<p className="text-[10px] text-indigo-400/60 group-hover:text-indigo-400 transition-colors mt-0.5">
|
||||
{sourceCount} {lang === 'de' ? (sourceCount === 1 ? 'Quelle' : 'Quellen') : (sourceCount === 1 ? 'source' : 'sources')}
|
||||
{' · '}{lang === 'de' ? 'Klicken fuer Details' : 'Click for details'}
|
||||
{' · '}{lang === 'de' ? 'Klicken für Details' : 'Click for details'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -380,7 +380,7 @@ export default function MarketSlide({ lang, market }: MarketSlideProps) {
|
||||
</div>
|
||||
<p className="text-[10px] text-red-400/60 group-hover:text-red-400 transition-colors mt-0.5">
|
||||
{sourceCount} {lang === 'de' ? (sourceCount === 1 ? 'Quelle' : 'Quellen') : (sourceCount === 1 ? 'source' : 'sources')}
|
||||
{' · '}{lang === 'de' ? 'Klicken fuer Details' : 'Click for details'}
|
||||
{' · '}{lang === 'de' ? 'Klicken für Details' : 'Click for details'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,817 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useRef, useMemo, useCallback, Fragment } from 'react'
|
||||
import { Language } from '@/lib/types'
|
||||
import GradientText from '../ui/GradientText'
|
||||
import FadeInView from '../ui/FadeInView'
|
||||
|
||||
interface MilestonesSlideProps { lang: Language }
|
||||
|
||||
const MONO: React.CSSProperties = {
|
||||
fontFamily: '"JetBrains Mono","SF Mono",ui-monospace,monospace',
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
}
|
||||
|
||||
const CSS_KF = `
|
||||
@keyframes msFlow { 0%{stroke-dashoffset:0} 100%{stroke-dashoffset:-18} }
|
||||
@keyframes msFadeIn { from{opacity:0} to{opacity:1} }
|
||||
@keyframes msScaleIn { from{opacity:0;transform:scale(.94)} to{opacity:1;transform:scale(1)} }
|
||||
@keyframes msHeadingDark {
|
||||
0%,100%{text-shadow:0 0 22px rgba(167,139,250,.3)}
|
||||
50% {text-shadow:0 0 40px rgba(167,139,250,.6)}
|
||||
}
|
||||
@keyframes msHeadingLight {
|
||||
0%,100%{text-shadow:0 0 22px rgba(124,58,237,.15)}
|
||||
50% {text-shadow:0 0 36px rgba(124,58,237,.30)}
|
||||
}
|
||||
@keyframes msPulse {
|
||||
0%,100%{r:9;opacity:.4}
|
||||
50% {r:14;opacity:.05}
|
||||
}
|
||||
`
|
||||
|
||||
// ── Light mode hook ───────────────────────────────────────────────────────────
|
||||
function useIsLight() {
|
||||
const [isLight, setIsLight] = useState(false)
|
||||
useEffect(() => {
|
||||
const check = () => setIsLight(document.documentElement.classList.contains('theme-light'))
|
||||
check()
|
||||
const obs = new MutationObserver(check)
|
||||
obs.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
|
||||
return () => obs.disconnect()
|
||||
}, [])
|
||||
return isLight
|
||||
}
|
||||
|
||||
// ── Themes ────────────────────────────────────────────────────────────────────
|
||||
const THEMES = {
|
||||
dark: {
|
||||
key: 'dark' as const,
|
||||
bg: 'radial-gradient(ellipse at 50% 25%, #1a0f34 0%, #0e0720 55%, #050210 100%)',
|
||||
ambient: 'radial-gradient(ellipse, rgba(167,139,250,.18), transparent 65%)',
|
||||
stars: true,
|
||||
fg: '#f7f5fc',
|
||||
fgSoft: 'rgba(236,233,247,.82)',
|
||||
fgMid: 'rgba(236,233,247,.72)',
|
||||
fgMuted: 'rgba(236,233,247,.62)',
|
||||
fgFaint: 'rgba(236,233,247,.55)',
|
||||
fgGhost: 'rgba(236,233,247,.45)',
|
||||
fgWhisper: 'rgba(236,233,247,.4)',
|
||||
accent: '#a78bfa',
|
||||
accent80: 'rgba(167,139,250,.8)',
|
||||
accent70: 'rgba(167,139,250,.7)',
|
||||
accent50: 'rgba(167,139,250,.5)',
|
||||
accent40: 'rgba(167,139,250,.4)',
|
||||
accent20: 'rgba(167,139,250,.2)',
|
||||
headingGrad: 'linear-gradient(90deg, #e9e2ff, #a78bfa 50%, #e9e2ff)',
|
||||
headingAnim: 'msHeadingDark 4s ease-in-out infinite',
|
||||
heuteText: '#e4d4ff',
|
||||
heutePillBg: 'rgba(14,8,28,.95)',
|
||||
heuteCore: '#f0e9ff',
|
||||
done: '#4ade80',
|
||||
doneBright: '#86efac',
|
||||
doneDeep: '#166534',
|
||||
doneSolid: '#22c55e',
|
||||
cardBase: 'rgba(14,8,28,',
|
||||
cardBaseA: '.9',
|
||||
cardBaseAH: '.95',
|
||||
cardTintTop: '18', cardTintTopH: '2e',
|
||||
cardTintMid: '08', cardTintMidH: '14',
|
||||
cardShadowSoft: '0 10px 24px rgba(0,0,0,.45)',
|
||||
cardShadowLift: (t: string) => `0 20px 44px ${t}33, 0 0 0 1px ${t}66, inset 0 1px 0 ${t}66`,
|
||||
statTintTop: '18', statTintTopH: '2a',
|
||||
statTintMid: '06',
|
||||
statShadowSoft: '0 10px 24px rgba(0,0,0,.45)',
|
||||
statShadowLift: (t: string) => `0 18px 40px ${t}33, 0 0 0 1px ${t}55, inset 0 1px 0 ${t}55`,
|
||||
modalScrim: 'rgba(5,2,16,.75)',
|
||||
modalBgMid: 'rgba(20,10,40,.97)',
|
||||
modalBgLow: 'rgba(14,8,28,.98)',
|
||||
modalShadow: (t: string) => `0 30px 80px rgba(0,0,0,.65), 0 0 60px ${t}33, inset 0 1px 0 ${t}55`,
|
||||
bulletBg: 'rgba(0,0,0,.3)',
|
||||
progressTrackBg: 'rgba(255,255,255,.08)',
|
||||
progressTrackBorder: 'rgba(167,139,250,.2)',
|
||||
dotTodoDeep: '#1a0f34',
|
||||
dotLitHi: 'rgba(255,255,255,.5)',
|
||||
dotSoftHi: 'rgba(255,255,255,.3)',
|
||||
sparkOp: 0.45,
|
||||
},
|
||||
light: {
|
||||
key: 'light' as const,
|
||||
bg: 'radial-gradient(ellipse at 50% 12%, #ffffff 0%, #f5efff 55%, #ebdfff 100%)',
|
||||
ambient: 'radial-gradient(ellipse, rgba(124,58,237,.14), transparent 65%)',
|
||||
stars: false,
|
||||
fg: '#1a0f34',
|
||||
fgSoft: 'rgba(26,15,52,.85)',
|
||||
fgMid: 'rgba(26,15,52,.72)',
|
||||
fgMuted: 'rgba(26,15,52,.62)',
|
||||
fgFaint: 'rgba(26,15,52,.50)',
|
||||
fgGhost: 'rgba(26,15,52,.40)',
|
||||
fgWhisper: 'rgba(26,15,52,.32)',
|
||||
accent: '#7c3aed',
|
||||
accent80: 'rgba(124,58,237,.8)',
|
||||
accent70: 'rgba(124,58,237,.75)',
|
||||
accent50: 'rgba(124,58,237,.55)',
|
||||
accent40: 'rgba(124,58,237,.4)',
|
||||
accent20: 'rgba(124,58,237,.18)',
|
||||
headingGrad: 'linear-gradient(90deg, #3b0e7a, #7c3aed 50%, #3b0e7a)',
|
||||
headingAnim: 'msHeadingLight 4s ease-in-out infinite',
|
||||
heuteText: '#4c1d95',
|
||||
heutePillBg: 'rgba(255,255,255,.98)',
|
||||
heuteCore: '#7c3aed',
|
||||
done: '#16a34a',
|
||||
doneBright: '#4ade80',
|
||||
doneDeep: '#14532d',
|
||||
doneSolid: '#22c55e',
|
||||
cardBase: 'rgba(255,255,255,',
|
||||
cardBaseA: '.92',
|
||||
cardBaseAH: '.98',
|
||||
cardTintTop: '22', cardTintTopH: '3a',
|
||||
cardTintMid: '10', cardTintMidH: '1c',
|
||||
cardShadowSoft: '0 10px 24px rgba(59,26,122,.10), 0 2px 6px rgba(59,26,122,.06)',
|
||||
cardShadowLift: (t: string) => `0 20px 44px ${t}38, 0 0 0 1px ${t}77, inset 0 1px 0 rgba(255,255,255,.9)`,
|
||||
statTintTop: '1e', statTintTopH: '34',
|
||||
statTintMid: '08',
|
||||
statShadowSoft: '0 10px 24px rgba(59,26,122,.10), 0 2px 6px rgba(59,26,122,.06)',
|
||||
statShadowLift: (t: string) => `0 18px 40px ${t}38, 0 0 0 1px ${t}77, inset 0 1px 0 rgba(255,255,255,.9)`,
|
||||
modalScrim: 'rgba(40,20,80,.28)',
|
||||
modalBgMid: 'rgba(255,255,255,.98)',
|
||||
modalBgLow: 'rgba(250,247,255,.98)',
|
||||
modalShadow: (t: string) => `0 30px 80px rgba(59,26,122,.25), 0 0 60px ${t}33, inset 0 1px 0 rgba(255,255,255,.9)`,
|
||||
bulletBg: 'rgba(124,58,237,.06)',
|
||||
progressTrackBg: 'rgba(124,58,237,.12)',
|
||||
progressTrackBorder: 'rgba(124,58,237,.25)',
|
||||
dotTodoDeep: '#faf5ff',
|
||||
dotLitHi: 'rgba(255,255,255,.85)',
|
||||
dotSoftHi: 'rgba(255,255,255,.55)',
|
||||
sparkOp: 0.55,
|
||||
},
|
||||
}
|
||||
|
||||
type Theme = typeof THEMES.dark
|
||||
|
||||
// ── Data ──────────────────────────────────────────────────────────────────────
|
||||
const TODAY_POSITION = 0.56
|
||||
|
||||
interface Milestone {
|
||||
id: string
|
||||
when: string
|
||||
tick: string
|
||||
title: { de: string; en: string }
|
||||
short: { de: string; en: string }
|
||||
body: { de: string; en: string }
|
||||
bullets: { de: string[]; en: string[] }
|
||||
tint: string
|
||||
done: boolean
|
||||
next?: boolean
|
||||
}
|
||||
|
||||
const MILESTONES: Milestone[] = [
|
||||
{
|
||||
id: 'ihk',
|
||||
when: 'Okt. 2025', tick: '10 · 25',
|
||||
title: { de: 'Gründerzuschuss & IHK', en: 'Founder Grant & IHK' },
|
||||
short: { de: 'Abstimmung mit Agentur für Arbeit und IHK Konstanz.', en: 'Coordination with Employment Agency and IHK Konstanz.' },
|
||||
body: {
|
||||
de: 'Seit Oktober 2025 Gründerzuschussantrag in Abstimmung mit der Agentur für Arbeit und der IHK Konstanz. Grundlage für die Unternehmensgründung.',
|
||||
en: 'Since October 2025, founder grant application in coordination with the Employment Agency and IHK Konstanz. Foundation for company formation.',
|
||||
},
|
||||
bullets: {
|
||||
de: ['Gründerzuschuss beantragt', 'Beratung IHK Konstanz', 'Businessplan finalisiert'],
|
||||
en: ['Founder grant applied', 'IHK Konstanz advisory', 'Business plan finalized'],
|
||||
},
|
||||
tint: '#a78bfa', done: true,
|
||||
},
|
||||
{
|
||||
id: 'brand',
|
||||
when: '11. Nov. 2025', tick: '11 · 25',
|
||||
title: { de: 'Markenanmeldung & Domains', en: 'Trademark Filing & Domains' },
|
||||
short: { de: 'DPMA-Anmeldung BreakPilot + Domain-Portfolio.', en: 'DPMA filing BreakPilot + domain portfolio.' },
|
||||
body: {
|
||||
de: 'Markenanmeldung BreakPilot beim DPMA am 11.11.2025. Domain-Kauf breakpilot.com, .de, .ai und brakepilot.com, .de, .ai am 21.11.2025.',
|
||||
en: 'BreakPilot trademark filed with DPMA on 11.11.2025. Domain purchase breakpilot.com, .de, .ai and brakepilot.com, .de, .ai on 21.11.2025.',
|
||||
},
|
||||
bullets: {
|
||||
de: ['DPMA-Markenanmeldung 11.11.2025', 'Domains .com .de .ai gesichert', 'Typo-Domains (.brakepilot) gesichert'],
|
||||
en: ['DPMA trademark filed 11.11.2025', 'Domains .com .de .ai secured', 'Typo domains (.brakepilot) secured'],
|
||||
},
|
||||
tint: '#a78bfa', done: true,
|
||||
},
|
||||
{
|
||||
id: 'dev',
|
||||
when: 'Jan. 2026', tick: '01 · 26',
|
||||
title: { de: 'Plattform-Entwicklung gestartet', en: 'Platform Development Started' },
|
||||
short: { de: '500.000+ Lines of Code, vollständige Architektur.', en: '500,000+ lines of code, full architecture.' },
|
||||
body: {
|
||||
de: 'Start der Plattform-Entwicklung mit 500.000+ Lines of Code. Vollständige Microservice-Architektur mit Go, Python und TypeScript.',
|
||||
en: 'Platform development started with 500,000+ lines of code. Full microservice architecture with Go, Python and TypeScript.',
|
||||
},
|
||||
bullets: {
|
||||
de: ['500K+ Lines of Code', 'Go + Python + TypeScript', 'Vollständige Architektur'],
|
||||
en: ['500K+ lines of code', 'Go + Python + TypeScript', 'Full architecture'],
|
||||
},
|
||||
tint: '#c084fc', done: true,
|
||||
},
|
||||
{
|
||||
id: 'dpma',
|
||||
when: '27. Mär. 2026', tick: '03 · 26',
|
||||
title: { de: 'Markeneintragung DPMA', en: 'DPMA Trademark Registration' },
|
||||
short: { de: 'BreakPilot offiziell eingetragen.', en: 'BreakPilot officially registered.' },
|
||||
body: {
|
||||
de: 'Markeneintragung BreakPilot beim Deutschen Patent- und Markenamt (DPMA) am 27.03.2026.',
|
||||
en: 'BreakPilot trademark registration at the German Patent and Trademark Office (DPMA) on 27.03.2026.',
|
||||
},
|
||||
bullets: {
|
||||
de: ['DPMA-Eintragung 27.03.2026', 'Markenschutz Deutschland'],
|
||||
en: ['DPMA registration 27.03.2026', 'Trademark protection Germany'],
|
||||
},
|
||||
tint: '#c084fc', done: true,
|
||||
},
|
||||
{
|
||||
id: 'rag',
|
||||
when: 'Apr. 2026', tick: '04 · 26',
|
||||
title: { de: 'RAG mit 375+ Dokumenten', en: 'RAG with 375+ Documents' },
|
||||
short: { de: 'EU + DACH Regularien & Normen indexiert.', en: 'EU + DACH regulations & standards indexed.' },
|
||||
body: {
|
||||
de: '375+ Gesetze, Verordnungen, Normen und Urteile in die RAG-Pipeline ingestiert. 25.000+ Prüfaspekte generiert.',
|
||||
en: '375+ laws, regulations, standards and rulings ingested into the RAG pipeline. 25,000+ audit controls generated.',
|
||||
},
|
||||
bullets: {
|
||||
de: ['375+ Dokumente im RAG', '25.000+ Prüfaspekte', 'EU + DACH Abdeckung'],
|
||||
en: ['375+ documents in RAG', '25,000+ audit controls', 'EU + DACH coverage'],
|
||||
},
|
||||
tint: '#c084fc', done: true,
|
||||
},
|
||||
{
|
||||
id: 'euipo',
|
||||
when: '1. Mai 2026', tick: '05 · 26',
|
||||
title: { de: 'Markenanmeldung EUIPO', en: 'EUIPO Trademark Filing' },
|
||||
short: { de: 'EU-weiter Markenschutz beantragt.', en: 'EU-wide trademark protection filed.' },
|
||||
body: {
|
||||
de: 'Markenanmeldung BreakPilot beim EUIPO (Amt der Europäischen Union für geistiges Eigentum) am 01.05.2026 für EU-weiten Markenschutz.',
|
||||
en: 'BreakPilot trademark filing with EUIPO (European Union Intellectual Property Office) on 01.05.2026 for EU-wide trademark protection.',
|
||||
},
|
||||
bullets: {
|
||||
de: ['EUIPO-Anmeldung 01.05.2026', 'EU-weiter Markenschutz'],
|
||||
en: ['EUIPO filing 01.05.2026', 'EU-wide trademark protection'],
|
||||
},
|
||||
tint: '#fbbf24', done: false, next: true,
|
||||
},
|
||||
{
|
||||
id: 'gmbh',
|
||||
when: 'Aug. 2026', tick: '08 · 26',
|
||||
title: { de: 'GmbH-Gründung', en: 'GmbH Incorporation' },
|
||||
short: { de: 'Breakpilot COMPLAI GmbH gegründet.', en: 'Breakpilot COMPLAI GmbH incorporated.' },
|
||||
body: {
|
||||
de: 'Gründung der Breakpilot COMPLAI GmbH im August 2026. Notartermin, Handelsregistereintrag, operative Aufnahme.',
|
||||
en: 'Incorporation of Breakpilot COMPLAI GmbH in August 2026. Notary appointment, commercial register entry, start of operations.',
|
||||
},
|
||||
bullets: {
|
||||
de: ['GmbH-Gründung August 2026', 'Handelsregistereintrag', 'Operativer Start'],
|
||||
en: ['GmbH incorporation August 2026', 'Commercial register entry', 'Start of operations'],
|
||||
},
|
||||
tint: '#fbbf24', done: false,
|
||||
},
|
||||
{
|
||||
id: 'customers',
|
||||
when: 'Aug. 2026', tick: '08 · 26',
|
||||
title: { de: '2 zahlende Kunden', en: '2 Paying Customers' },
|
||||
short: { de: 'Erste Umsätze ab Gründung.', en: 'First revenue from incorporation.' },
|
||||
body: {
|
||||
de: 'Zwei zahlende Kunden ab August 2026 — Validierung des Produkts im Maschinenbau-Umfeld mit echten Compliance-Anforderungen.',
|
||||
en: 'Two paying customers from August 2026 — product validation in manufacturing with real compliance requirements.',
|
||||
},
|
||||
bullets: {
|
||||
de: ['2 zahlende Kunden', 'Maschinenbau-Validierung', 'Erste Umsätze'],
|
||||
en: ['2 paying customers', 'Manufacturing validation', 'First revenue'],
|
||||
},
|
||||
tint: '#fbbf24', done: false,
|
||||
},
|
||||
{
|
||||
id: 'beta',
|
||||
when: 'Q3 2026', tick: 'Q3 · 26',
|
||||
title: { de: 'Öffentliches Beta', en: 'Public Beta' },
|
||||
short: { de: 'Beta-Launch mit ersten zahlenden Kunden.', en: 'Beta launch with first paying customers.' },
|
||||
body: {
|
||||
de: 'Öffentliches Beta-Release der Plattform. Erste zahlende Kunden aus dem Pilotprogramm gehen live.',
|
||||
en: 'Public beta release of the platform. First paying customers from the pilot program go live.',
|
||||
},
|
||||
bullets: {
|
||||
de: ['Public Beta verfügbar', 'Onboarding-Prozess live', 'Feedback-Loop etabliert'],
|
||||
en: ['Public beta available', 'Onboarding process live', 'Feedback loop established'],
|
||||
},
|
||||
tint: '#f59e0b', done: false,
|
||||
},
|
||||
]
|
||||
|
||||
interface StatItem { k: { de: string; en: string }; v: string; tint: string }
|
||||
|
||||
const STATS: StatItem[] = [
|
||||
{ k: { de: 'Gesetze & Dokumente im RAG', en: 'Laws & Docs in RAG' }, v: '385', tint: '#a78bfa' },
|
||||
{ k: { de: 'Atomare Controls', en: 'Atomic Controls' }, v: '25.000+', tint: '#c084fc' },
|
||||
{ k: { de: 'Compliance-Module', en: 'Compliance Modules' }, v: '12', tint: '#fbbf24' },
|
||||
{ k: { de: 'Pilotkunden', en: 'Pilot Customers' }, v: '2', tint: '#f59e0b' },
|
||||
{ k: { de: 'Lines of Code', en: 'Lines of Code' }, v: '500.000+', tint: '#8b5cf6' },
|
||||
]
|
||||
|
||||
// ── Star Field ────────────────────────────────────────────────────────────────
|
||||
function StarField() {
|
||||
const stars = useMemo(() => {
|
||||
let s = 77
|
||||
const r = () => { s = (s * 9301 + 49297) % 233280; return s / 233280 }
|
||||
return Array.from({ length: 95 }, () => ({ x: r() * 100, y: r() * 100, size: r() * 1.4 + 0.3, op: r() * 0.5 + 0.15 }))
|
||||
}, [])
|
||||
return (
|
||||
<div style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }}>
|
||||
{stars.map((st, i) => (
|
||||
<div key={i} style={{
|
||||
position: 'absolute', left: `${st.x}%`, top: `${st.y}%`,
|
||||
width: st.size, height: st.size, borderRadius: '50%',
|
||||
background: '#fff', opacity: st.op,
|
||||
boxShadow: `0 0 ${st.size * 3}px rgba(180,160,255,.7)`,
|
||||
}} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SoftGrid({ t }: { t: Theme }) {
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0, pointerEvents: 'none',
|
||||
backgroundImage: `radial-gradient(${t.accent20} 1px, transparent 1px)`,
|
||||
backgroundSize: '28px 28px',
|
||||
maskImage: 'radial-gradient(ellipse at center, #000 40%, transparent 85%)',
|
||||
WebkitMaskImage: 'radial-gradient(ellipse at center, #000 40%, transparent 85%)',
|
||||
opacity: 0.8,
|
||||
}} />
|
||||
)
|
||||
}
|
||||
|
||||
// ── Timeline ──────────────────────────────────────────────────────────────────
|
||||
interface MilestoneWithPos extends Milestone { x: number; row: 'top' | 'bottom' }
|
||||
|
||||
function Timeline({ onSelect, selectedId, t, de }: {
|
||||
onSelect: (m: Milestone) => void
|
||||
selectedId: string | null
|
||||
t: Theme
|
||||
de: boolean
|
||||
}) {
|
||||
const trackW = 1160
|
||||
const innerPad = 120
|
||||
const usableW = trackW - innerPad * 2
|
||||
const positions = MILESTONES.map((_, i) => innerPad + (usableW * i) / (MILESTONES.length - 1))
|
||||
const todayX = innerPad + usableW * TODAY_POSITION
|
||||
|
||||
const layout: MilestoneWithPos[] = MILESTONES.map((m, i) => ({
|
||||
...m, x: positions[i],
|
||||
row: i % 2 === 0 ? 'top' : 'bottom',
|
||||
}))
|
||||
|
||||
const railColor = t.key === 'dark' ? '#a78bfa' : '#7c3aed'
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative', width: trackW, height: 360, margin: '0 auto' }}>
|
||||
<svg viewBox={`0 0 ${trackW} 360`} preserveAspectRatio="none"
|
||||
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%' }}>
|
||||
<defs>
|
||||
<linearGradient id="msTrackBg" x1="0" x2="1">
|
||||
<stop offset="0" stopColor={railColor} stopOpacity={t.key === 'dark' ? .18 : .28} />
|
||||
<stop offset=".5" stopColor={railColor} stopOpacity={t.key === 'dark' ? .28 : .38} />
|
||||
<stop offset="1" stopColor={railColor} stopOpacity={t.key === 'dark' ? .18 : .28} />
|
||||
</linearGradient>
|
||||
<filter id="msGlow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="3" result="b"/>
|
||||
<feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
{/* rail background */}
|
||||
<line x1={innerPad} y1={180} x2={trackW - innerPad} y2={180}
|
||||
stroke="url(#msTrackBg)" strokeWidth="2.5" />
|
||||
{/* past progress */}
|
||||
<line x1={innerPad} y1={180} x2={todayX} y2={180}
|
||||
stroke={t.done} strokeWidth="3" opacity={t.key === 'dark' ? .85 : .9} />
|
||||
{/* future dashed */}
|
||||
<line x1={todayX} y1={180} x2={trackW - innerPad} y2={180}
|
||||
stroke="#f59e0b" strokeWidth="1.75" strokeDasharray="4 5"
|
||||
opacity={t.key === 'dark' ? .6 : .75}
|
||||
style={{ animation: 'msFlow 1.8s linear infinite' }} />
|
||||
|
||||
{/* connector stubs */}
|
||||
{layout.map((m) => (
|
||||
<line key={m.id}
|
||||
x1={m.x} y1={180}
|
||||
x2={m.x} y2={m.row === 'top' ? 154 : 200}
|
||||
stroke={m.done ? t.done : m.tint}
|
||||
strokeOpacity={t.key === 'dark' ? (m.done ? .6 : .55) : (m.done ? .7 : .65)}
|
||||
strokeWidth="1"
|
||||
strokeDasharray={m.done ? '0' : '3 3'} />
|
||||
))}
|
||||
|
||||
{/* HEUTE marker — circles only; pill is HTML below */}
|
||||
<g transform={`translate(${todayX} 180)`}>
|
||||
<circle r="14" fill={t.accent} opacity=".15" />
|
||||
<circle r="9" fill={t.accent} opacity=".4">
|
||||
<animate attributeName="r" values="9;14;9" dur="2s" repeatCount="indefinite" />
|
||||
<animate attributeName="opacity" values=".4;.05;.4" dur="2s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
<circle r="6" fill={t.heuteCore} stroke={t.accent} strokeWidth="2" filter="url(#msGlow)" />
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
{/* HEUTE pill — HTML so it sits above milestone cards */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
left: todayX - 30, top: 146,
|
||||
width: 60, height: 18,
|
||||
borderRadius: 9,
|
||||
background: t.heutePillBg,
|
||||
border: `1px solid ${t.accent}99`,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
zIndex: 10, pointerEvents: 'none',
|
||||
...MONO, fontSize: 9.5, letterSpacing: 2.5, fontWeight: 700,
|
||||
color: t.heuteText,
|
||||
}}>HEUTE</div>
|
||||
|
||||
{layout.map((m) => (
|
||||
<MilestoneNode key={m.id} m={m} t={t} de={de}
|
||||
onClick={() => onSelect(m)}
|
||||
active={selectedId === m.id} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MilestoneNode({ m, onClick, active, t, de }: {
|
||||
m: MilestoneWithPos; onClick: () => void; active: boolean; t: Theme; de: boolean
|
||||
}) {
|
||||
const [hover, setHover] = useState(false)
|
||||
const lit = hover || active
|
||||
const isTop = m.row === 'top'
|
||||
const cardY = isTop ? 4 : 200
|
||||
const nodeColor = m.done ? t.done : m.tint
|
||||
|
||||
const bgTopA = lit ? m.tint + t.cardTintTopH : m.tint + t.cardTintTop
|
||||
const bgMidA = lit ? m.tint + t.cardTintMidH : m.tint + t.cardTintMid
|
||||
const cardBg = `linear-gradient(180deg, ${bgTopA} 0%, ${bgMidA} 55%, ${t.cardBase}${lit ? t.cardBaseAH : t.cardBaseA})`
|
||||
const badge = m.done ? (de ? 'erledigt' : 'done') : (m.next ? (de ? 'als nächstes' : 'next') : (de ? 'geplant' : 'plan'))
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* dot */}
|
||||
<div
|
||||
onClick={onClick}
|
||||
onMouseEnter={() => setHover(true)}
|
||||
onMouseLeave={() => setHover(false)}
|
||||
style={{
|
||||
position: 'absolute', left: m.x - 14, top: 180 - 14,
|
||||
width: 28, height: 28, borderRadius: '50%',
|
||||
background: m.done
|
||||
? `radial-gradient(circle at 35% 30%, ${t.doneBright}, ${t.doneSolid} 60%, ${t.doneDeep})`
|
||||
: `radial-gradient(circle at 35% 30%, ${m.tint}dd, ${m.tint}66 60%, ${t.dotTodoDeep})`,
|
||||
border: `2px solid ${lit ? '#fff' : nodeColor}`,
|
||||
boxShadow: lit
|
||||
? `0 0 22px ${nodeColor}, 0 0 44px ${nodeColor}66, inset 0 1px 0 ${t.dotLitHi}`
|
||||
: `0 0 10px ${nodeColor}88, inset 0 1px 0 ${t.dotSoftHi}`,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: '#fff', fontSize: 11, fontWeight: 700,
|
||||
cursor: 'pointer', zIndex: 5,
|
||||
transition: 'all .25s',
|
||||
transform: lit ? 'scale(1.15)' : 'scale(1)',
|
||||
}}>
|
||||
{m.done ? '✓' : (m.next ? '◉' : '○')}
|
||||
</div>
|
||||
|
||||
{/* card */}
|
||||
<div
|
||||
onClick={onClick}
|
||||
onMouseEnter={() => setHover(true)}
|
||||
onMouseLeave={() => setHover(false)}
|
||||
style={{
|
||||
position: 'absolute', left: m.x - 112, top: cardY,
|
||||
width: 224, height: 150, padding: '12px 14px',
|
||||
borderRadius: 12,
|
||||
background: cardBg,
|
||||
border: `1px solid ${lit ? m.tint : m.tint + '55'}`,
|
||||
boxShadow: lit ? t.cardShadowLift(m.tint) : t.cardShadowSoft,
|
||||
cursor: 'pointer', zIndex: 4,
|
||||
transition: 'all .25s',
|
||||
transform: lit ? `translateY(${isTop ? -2 : 2}px)` : 'translateY(0)',
|
||||
display: 'flex', flexDirection: 'column', gap: 6,
|
||||
backdropFilter: t.key === 'light' ? 'blur(6px)' : 'none',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{
|
||||
...MONO, fontSize: 10, letterSpacing: 1.5, fontWeight: 700,
|
||||
color: m.done ? t.done : m.tint, textTransform: 'uppercase' as const,
|
||||
}}>{m.tick}</span>
|
||||
<span style={{ flex: 1, height: 1, background: `${m.tint}44` }} />
|
||||
<span style={{
|
||||
...MONO, fontSize: 9, letterSpacing: 2, fontWeight: 700,
|
||||
color: m.done ? t.done : m.tint, textTransform: 'uppercase' as const, opacity: .85,
|
||||
}}>{badge}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 13, fontWeight: 700, color: t.fg, letterSpacing: -0.2, lineHeight: 1.25 }}>
|
||||
{de ? m.title.de : m.title.en}
|
||||
</div>
|
||||
<div style={{ fontSize: 10.5, lineHeight: 1.45, color: lit ? t.fgSoft : t.fgMuted, transition: 'color .25s' }}>
|
||||
{de ? m.short.de : m.short.en}
|
||||
</div>
|
||||
<div style={{
|
||||
marginTop: 'auto',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
paddingTop: 6, borderTop: `1px dashed ${m.tint}44`,
|
||||
}}>
|
||||
<span style={{ fontSize: 10, color: t.fgFaint }}>{m.when}</span>
|
||||
<span style={{
|
||||
fontSize: 10, color: m.tint, fontWeight: 700,
|
||||
opacity: lit ? 1 : 0.55,
|
||||
transform: `translateX(${lit ? 0 : -4}px)`,
|
||||
transition: 'all .25s',
|
||||
}}>{de ? 'Details →' : 'Details →'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Stat Card ─────────────────────────────────────────────────────────────────
|
||||
function StatCard({ item, t, de }: { item: StatItem; t: Theme; de: boolean }) {
|
||||
const [hover, setHover] = useState(false)
|
||||
const bgTop = hover ? item.tint + t.statTintTopH : item.tint + t.statTintTop
|
||||
const bgMid = item.tint + t.statTintMid
|
||||
return (
|
||||
<div
|
||||
onMouseEnter={() => setHover(true)}
|
||||
onMouseLeave={() => setHover(false)}
|
||||
style={{
|
||||
position: 'relative', padding: '14px 18px', borderRadius: 12,
|
||||
background: `linear-gradient(180deg, ${bgTop} 0%, ${bgMid} 60%, ${t.cardBase}${t.cardBaseA})`,
|
||||
border: `1px solid ${hover ? item.tint : item.tint + '55'}`,
|
||||
boxShadow: hover ? t.statShadowLift(item.tint) : t.statShadowSoft,
|
||||
transform: hover ? 'translateY(-3px)' : 'translateY(0)',
|
||||
transition: 'all .25s',
|
||||
overflow: 'hidden',
|
||||
backdropFilter: t.key === 'light' ? 'blur(6px)' : 'none',
|
||||
}}>
|
||||
<div style={{
|
||||
position: 'absolute', right: 10, top: 10, width: 6, height: 6,
|
||||
borderRadius: '50%', background: item.tint, opacity: .9,
|
||||
boxShadow: `0 0 10px ${item.tint}`,
|
||||
}} />
|
||||
<div style={{ ...MONO, fontSize: 9.5, letterSpacing: 2, color: item.tint, textTransform: 'uppercase' as const, fontWeight: 700, marginBottom: 6 }}>
|
||||
{de ? item.k.de : item.k.en}
|
||||
</div>
|
||||
<div style={{ fontSize: 32, fontWeight: 700, color: t.fg, letterSpacing: -0.8, lineHeight: 1 }}>
|
||||
{item.v}
|
||||
</div>
|
||||
<svg viewBox="0 0 100 16" preserveAspectRatio="none"
|
||||
style={{ width: '100%', height: 14, marginTop: 8, opacity: hover ? 1 : t.sparkOp, transition: 'opacity .25s' }}>
|
||||
<defs>
|
||||
<linearGradient id={`spark-${item.tint.replace('#', '')}`} x1="0" x2="1">
|
||||
<stop offset="0" stopColor={item.tint} stopOpacity="0" />
|
||||
<stop offset=".5" stopColor={item.tint} stopOpacity=".9" />
|
||||
<stop offset="1" stopColor={item.tint} stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="M 0 10 L 15 8 L 30 11 L 48 6 L 62 9 L 78 4 L 100 2"
|
||||
stroke={`url(#spark-${item.tint.replace('#', '')})`} strokeWidth="1.5" fill="none" />
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Detail modal ──────────────────────────────────────────────────────────────
|
||||
function DetailModal({ item, onClose, t, de }: {
|
||||
item: Milestone | null; onClose: () => void; t: Theme; de: boolean
|
||||
}) {
|
||||
useEffect(() => {
|
||||
if (!item) return
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [item, onClose])
|
||||
|
||||
if (!item) return null
|
||||
const tint = item.tint
|
||||
const badge = item.done
|
||||
? (de ? 'ABGESCHLOSSEN' : 'COMPLETED')
|
||||
: (item.next ? (de ? 'ALS NÄCHSTES' : 'NEXT UP') : (de ? 'GEPLANT' : 'PLANNED'))
|
||||
const badgeColor = item.done ? t.done : tint
|
||||
|
||||
return (
|
||||
<div onClick={onClose} style={{
|
||||
position: 'absolute', inset: 0, zIndex: 50,
|
||||
background: t.modalScrim, backdropFilter: 'blur(8px)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
animation: 'msFadeIn .2s ease-out',
|
||||
}}>
|
||||
<div onClick={e => e.stopPropagation()} style={{
|
||||
width: 580, maxWidth: '88%',
|
||||
background: `linear-gradient(180deg, ${tint}22 0%, ${t.modalBgMid} 50%, ${t.modalBgLow} 100%)`,
|
||||
border: `1px solid ${tint}77`,
|
||||
borderRadius: 16,
|
||||
boxShadow: t.modalShadow(tint),
|
||||
padding: '24px 28px', color: t.fg,
|
||||
animation: 'msScaleIn .22s ease-out',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16 }}>
|
||||
<div style={{
|
||||
width: 42, height: 42, borderRadius: 11,
|
||||
background: `linear-gradient(135deg, ${tint}66, ${tint}22)`,
|
||||
border: `1px solid ${tint}`,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: t.key === 'light' ? tint : '#fff', fontSize: 17, fontWeight: 700,
|
||||
boxShadow: `0 0 20px ${tint}66`,
|
||||
}}>{item.done ? '✓' : (item.next ? '◉' : '○')}</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 4 }}>
|
||||
<span style={{
|
||||
...MONO, fontSize: 9.5, letterSpacing: 2.5, color: badgeColor,
|
||||
textTransform: 'uppercase' as const, fontWeight: 700,
|
||||
padding: '2px 8px', borderRadius: 4,
|
||||
background: `${badgeColor}22`, border: `1px solid ${badgeColor}66`,
|
||||
}}>{badge}</span>
|
||||
<span style={{ ...MONO, fontSize: 10, color: t.fgFaint }}>{item.when}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 700, color: t.fg, letterSpacing: -0.3 }}>
|
||||
{de ? item.title.de : item.title.en}
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} style={{
|
||||
background: 'transparent', border: `1px solid ${tint}66`, color: t.fg,
|
||||
width: 32, height: 32, borderRadius: 8, cursor: 'pointer', fontSize: 14,
|
||||
}}>✕</button>
|
||||
</div>
|
||||
<div style={{ fontSize: 13, lineHeight: 1.6, color: t.fgSoft, marginBottom: 16 }}>
|
||||
{de ? item.body.de : item.body.en}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{(de ? item.bullets.de : item.bullets.en).map((b, i) => (
|
||||
<div key={i} style={{
|
||||
display: 'flex', alignItems: 'flex-start', gap: 10,
|
||||
padding: '9px 13px', borderRadius: 8,
|
||||
background: t.bulletBg, border: `1px solid ${tint}44`,
|
||||
}}>
|
||||
<span style={{ color: item.done ? t.done : tint, fontSize: 12, marginTop: 1 }}>
|
||||
{item.done ? '✓' : '▸'}
|
||||
</span>
|
||||
<span style={{ fontSize: 12, lineHeight: 1.5, color: t.fgSoft }}>{b}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Inner slide (fixed 1280×680) ──────────────────────────────────────────────
|
||||
function MilestonesInner({ t, de, sel, setSel }: {
|
||||
t: Theme; de: boolean
|
||||
sel: Milestone | null
|
||||
setSel: (m: Milestone | null) => void
|
||||
}) {
|
||||
const doneCnt = useMemo(() => MILESTONES.filter(m => m.done).length, [])
|
||||
const total = MILESTONES.length
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'relative', width: 1280, height: 600, overflow: 'hidden',
|
||||
background: t.bg, color: t.fg,
|
||||
fontFamily: '"Inter", system-ui, sans-serif', WebkitFontSmoothing: 'antialiased',
|
||||
}}>
|
||||
{/* Ambient glow */}
|
||||
<div style={{
|
||||
position: 'absolute', top: -120, left: '50%', transform: 'translateX(-50%)',
|
||||
width: 800, height: 500, borderRadius: '50%',
|
||||
background: t.ambient, filter: 'blur(50px)', pointerEvents: 'none',
|
||||
}} />
|
||||
|
||||
{t.stars ? <StarField /> : <SoftGrid t={t} />}
|
||||
|
||||
{/* Progress indicator */}
|
||||
<div style={{
|
||||
position: 'absolute', top: 36, right: 52, display: 'flex', alignItems: 'center', gap: 10, zIndex: 3,
|
||||
}}>
|
||||
<div style={{ ...MONO, fontSize: 10, letterSpacing: 2, color: t.fgMuted, textTransform: 'uppercase' as const, fontWeight: 700 }}>
|
||||
{de ? 'Fortschritt' : 'Progress'}
|
||||
</div>
|
||||
<div style={{
|
||||
width: 120, height: 6, background: t.progressTrackBg, borderRadius: 3, overflow: 'hidden',
|
||||
border: `1px solid ${t.progressTrackBorder}`,
|
||||
}}>
|
||||
<div style={{
|
||||
width: `${(doneCnt / total) * 100}%`, height: '100%',
|
||||
background: `linear-gradient(90deg, ${t.done}, ${t.accent})`,
|
||||
boxShadow: `0 0 12px ${t.done}99`,
|
||||
}} />
|
||||
</div>
|
||||
<div style={{ ...MONO, fontSize: 11, color: t.fg, fontWeight: 700 }}>
|
||||
<span style={{ color: t.done }}>{doneCnt}</span>
|
||||
<span style={{ color: t.fgWhisper }}> / {total}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tip */}
|
||||
<div style={{
|
||||
position: 'absolute', top: 36, left: 52, ...MONO, fontSize: 10,
|
||||
letterSpacing: 2, color: t.fgGhost, textTransform: 'uppercase' as const, fontWeight: 700,
|
||||
display: 'flex', alignItems: 'center', gap: 8, zIndex: 3,
|
||||
}}>
|
||||
<span>{de ? 'Tipp:' : 'Tip:'}</span>
|
||||
<span style={{ color: t.accent70 }}>{de ? 'Klick auf einen Meilenstein' : 'Click any milestone'}</span>
|
||||
</div>
|
||||
|
||||
{/* Timeline */}
|
||||
<div style={{ position: 'relative', marginTop: 68 }}>
|
||||
<Timeline onSelect={setSel} selectedId={sel?.id ?? null} t={t} de={de} />
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div style={{
|
||||
position: 'absolute', left: 40, right: 40, bottom: 36,
|
||||
display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: 14,
|
||||
}}>
|
||||
{STATS.map(s => <StatCard key={s.tint} item={s} t={t} de={de} />)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{
|
||||
position: 'absolute', left: 0, right: 0, bottom: 14, textAlign: 'center',
|
||||
...MONO, fontSize: 9, letterSpacing: 3, color: t.accent40,
|
||||
textTransform: 'uppercase' as const, fontWeight: 700,
|
||||
}}>
|
||||
{de ? 'Stand heute · live-Metriken aus der Plattform' : 'As of today · live metrics from the platform'}
|
||||
</div>
|
||||
|
||||
<DetailModal item={sel} onClose={() => setSel(null)} t={t} de={de} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main slide ────────────────────────────────────────────────────────────────
|
||||
const INNER_W = 1280
|
||||
const INNER_H = 600
|
||||
|
||||
export default function MilestonesSlide({ lang }: MilestonesSlideProps) {
|
||||
const de = lang === 'de'
|
||||
const isLight = useIsLight()
|
||||
const t = isLight ? THEMES.light : THEMES.dark
|
||||
const [sel, setSel] = useState<Milestone | null>(null)
|
||||
const [scale, setScale] = useState(1)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const calcScale = useCallback(() => {
|
||||
if (containerRef.current) {
|
||||
const w = containerRef.current.offsetWidth
|
||||
setScale(Math.min(w / INNER_W, 1))
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
calcScale()
|
||||
const obs = new ResizeObserver(calcScale)
|
||||
if (containerRef.current) obs.observe(containerRef.current)
|
||||
return () => obs.disconnect()
|
||||
}, [calcScale])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<style>{CSS_KF}</style>
|
||||
|
||||
<FadeInView className="text-center mb-1">
|
||||
<h2 className="text-5xl md:text-6xl font-bold mb-1">
|
||||
<GradientText>{de ? 'Meilensteine' : 'Milestones'}</GradientText>
|
||||
</h2>
|
||||
<p className="text-lg text-white/50 max-w-2xl mx-auto">
|
||||
{de ? 'Von der Idee zur GmbH — was wir bereits erreicht haben' : 'From idea to GmbH — what we have already achieved'}
|
||||
</p>
|
||||
</FadeInView>
|
||||
|
||||
<FadeInView delay={0.1}>
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: INNER_H * scale,
|
||||
overflow: 'hidden',
|
||||
borderRadius: 16,
|
||||
transform: 'scale(1.12)',
|
||||
transformOrigin: 'top center',
|
||||
marginBottom: -40,
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
position: 'absolute', top: 0, left: 0,
|
||||
width: INNER_W, height: INNER_H,
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: 'top left',
|
||||
}}>
|
||||
<MilestonesInner t={t} de={de} sel={sel} setSel={setSel} />
|
||||
</div>
|
||||
</div>
|
||||
</FadeInView>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -25,7 +25,7 @@ interface ProblemCardData {
|
||||
sources: SourceInfo[]
|
||||
}
|
||||
|
||||
// Quellenangaben fuer jede Behauptung
|
||||
// Quellenangaben für jede Behauptung
|
||||
const cardSources: ProblemCardData[] = [
|
||||
{
|
||||
// KI-Dilemma: Maschinenbauer wollen KI, aber nicht US-SaaS
|
||||
@@ -34,7 +34,7 @@ const cardSources: ProblemCardData[] = [
|
||||
name: 'Bitkom Cloud Monitor 2024 — Industrieunternehmen',
|
||||
url: 'https://www.bitkom.org/Themen/Datenschutz-Sicherheit/Cloud-Monitor',
|
||||
date: '2024',
|
||||
excerpt_de: 'Laut Bitkom Cloud Monitor lehnen 64% der deutschen Industrieunternehmen US-Cloud-Dienste fuer sensible Daten ab. Im Maschinenbau liegt die Ablehnung bei ueber 70%. Unternehmen wollen KI nutzen, aber nicht auf Kosten ihrer Datensouveraenitaet.',
|
||||
excerpt_de: 'Laut Bitkom Cloud Monitor lehnen 64% der deutschen Industrieunternehmen US-Cloud-Dienste für sensible Daten ab. Im Maschinenbau liegt die Ablehnung bei über 70%. Unternehmen wollen KI nutzen, aber nicht auf Kosten ihrer Datensouveraenitaet.',
|
||||
excerpt_en: 'According to Bitkom Cloud Monitor, 64% of German industrial companies reject US cloud services for sensitive data. In machine manufacturing, rejection exceeds 70%. Companies want AI but not at the cost of their data sovereignty.',
|
||||
},
|
||||
{
|
||||
@@ -53,7 +53,7 @@ const cardSources: ProblemCardData[] = [
|
||||
name: 'Schrems II — EuGH C-311/18',
|
||||
url: 'https://curia.europa.eu/juris/liste.jsf?num=C-311/18',
|
||||
date: '2020',
|
||||
excerpt_de: 'Der EuGH erklaerte das EU-US Privacy Shield fuer ungueltig. US-Unternehmen unterliegen dem CLOUD Act und dem FISA 702 — auch fuer Daten auf europaeischen Servern. Selbst EU-Rechenzentren von AWS, Google und Microsoft bieten keinen Schutz vor US-Zugriff.',
|
||||
excerpt_de: 'Der EuGH erklaerte das EU-US Privacy Shield für ungueltig. US-Unternehmen unterliegen dem CLOUD Act und dem FISA 702 — auch für Daten auf europaeischen Servern. Selbst EU-Rechenzentren von AWS, Google und Microsoft bieten keinen Schutz vor US-Zugriff.',
|
||||
excerpt_en: 'The CJEU invalidated the EU-US Privacy Shield. US companies are subject to the CLOUD Act and FISA 702 — even for data on European servers. Even EU data centers of AWS, Google and Microsoft offer no protection from US access.',
|
||||
},
|
||||
],
|
||||
@@ -65,14 +65,14 @@ const cardSources: ProblemCardData[] = [
|
||||
name: 'VDMA — Compliance-Kosten im Maschinenbau',
|
||||
url: 'https://www.vdma.org/',
|
||||
date: '2024',
|
||||
excerpt_de: 'Externe Pentests kosten 15.000-40.000 EUR pro Durchlauf, CE-Software-Risikobeurteilungen 10.000-25.000 EUR. Diese Pruefungen erfolgen einmal jaehrlich und decken nur eine Momentaufnahme ab. KMU mit 10-500 Mitarbeitern koennen sich weder Personal noch Budget fuer kontinuierliche Compliance leisten.',
|
||||
excerpt_de: 'Externe Pentests kosten 15.000-40.000 EUR pro Durchlauf, CE-Software-Risikobeurteilungen 10.000-25.000 EUR. Diese Prüfungen erfolgen einmal jaehrlich und decken nur eine Momentaufnahme ab. KMU mit 10-500 Mitarbeitern können sich weder Personal noch Budget für kontinuierliche Compliance leisten.',
|
||||
excerpt_en: 'External pentests cost EUR 15,000-40,000 per run, CE software risk assessments EUR 10,000-25,000. These audits occur annually, covering only a snapshot. SMEs with 10-500 employees can afford neither staff nor budget for continuous compliance.',
|
||||
},
|
||||
{
|
||||
name: 'Compliance-Markt: Top-10 Anbieter >$1,1 Mrd. Umsatz',
|
||||
url: 'https://sacra.com/c/vanta/',
|
||||
date: '2025',
|
||||
excerpt_de: 'Vanta ($220M ARR, $4,15 Mrd. Bewertung), Drata ($100M), OneTrust ($500M+), DataGuard (€52M). Der Markt ist validiert — aber keiner dieser Anbieter kombiniert Code-Security mit Compliance fuer den Maschinenbau.',
|
||||
excerpt_de: 'Vanta ($220M ARR, $4,15 Mrd. Bewertung), Drata ($100M), OneTrust ($500M+), DataGuard (€52M). Der Markt ist validiert — aber keiner dieser Anbieter kombiniert Code-Security mit Compliance für den Maschinenbau.',
|
||||
excerpt_en: 'Vanta ($220M ARR, $4.15B valuation), Drata ($100M), OneTrust ($500M+), DataGuard (€52M). The market is validated — but none of these providers combine code security with compliance for manufacturing.',
|
||||
},
|
||||
],
|
||||
@@ -194,19 +194,29 @@ export default function ProblemSlide({ lang }: ProblemSlideProps) {
|
||||
<p className="text-[10px] text-indigo-400/60 group-hover:text-indigo-400 transition-colors">
|
||||
{sourceCount} {lang === 'de' ? (sourceCount === 1 ? 'Quelle' : 'Quellen') : (sourceCount === 1 ? 'source' : 'sources')}
|
||||
{' · '}
|
||||
{lang === 'de' ? 'Klicken fuer Details' : 'Click for details'}
|
||||
{lang === 'de' ? 'Klicken für Details' : 'Click for details'}
|
||||
</p>
|
||||
</GlassCard>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<FadeInView delay={0.8} className="max-w-3xl mx-auto">
|
||||
<blockquote className="text-center">
|
||||
<p className="text-lg md:text-xl text-white/70 italic leading-relaxed">
|
||||
“{i.problem.quote}”
|
||||
</p>
|
||||
</blockquote>
|
||||
<FadeInView delay={0.8} className="max-w-4xl mx-auto">
|
||||
<div className="bg-gradient-to-r from-amber-500/10 to-indigo-500/10 border border-amber-500/20 rounded-xl px-6 py-5">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-amber-500 to-orange-600 flex items-center justify-center shrink-0 shadow-lg">
|
||||
<Shield className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-amber-300 mb-1">
|
||||
{lang === 'de' ? 'Die Konsequenz' : 'The Consequence'}
|
||||
</h3>
|
||||
<p className="text-sm text-white/50 leading-relaxed">
|
||||
{i.problem.quote}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FadeInView>
|
||||
|
||||
{/* Source Modals */}
|
||||
|
||||
@@ -24,9 +24,9 @@ const MODULES = [
|
||||
{ icon: UserCheck, color: '#14b8a6', de: 'Consent Management', en: 'Consent Management', descDe: 'Einwilligungen, Cookie-Banner, Widerruf', descEn: 'Consent, cookie banner, withdrawal' },
|
||||
{ icon: AlertTriangle, color: '#f59e0b', de: 'Notfallpläne', en: 'Incident Response', descDe: 'Datenschutzvorfälle, Meldepflichten, Eskalation', descEn: 'Data breaches, reporting obligations, escalation' },
|
||||
{ icon: Brain, color: '#a855f7', de: 'Compliance LLM', en: 'Compliance LLM', descDe: 'GPT für Text und Audio — sicher in der EU', descEn: 'GPT for text and audio — securely in EU' },
|
||||
{ icon: Shield, color: '#8b5cf6', de: 'Cookie-Generator', en: 'Cookie Generator', descDe: 'Cookie-Banner, Consent-Konfiguration', descEn: 'Cookie banner, consent configuration' },
|
||||
{ icon: Shield, color: '#8b5cf6', de: 'Tender Matching', en: 'Tender Matching', descDe: 'Kundenanfragen (RFQ) gegen Codebase prüfen', descEn: 'Verify customer RFQs against codebase' },
|
||||
{ icon: GraduationCap, color: '#ec4899', de: 'Academy', en: 'Academy', descDe: 'Online-Schulungen für GF und Mitarbeiter', descEn: 'Online training for management and employees' },
|
||||
{ icon: Puzzle, color: '#0ea5e9', de: 'Integration in Kundenprozesse', en: 'Process Integration', descDe: 'Ticketsysteme, Workflows', descEn: 'Ticket systems, workflows' },
|
||||
{ icon: Puzzle, color: '#0ea5e9', de: 'Compliance Optimizer', en: 'Compliance Optimizer', descDe: 'Maximale KI-Nutzung im regulatorischen Rahmen', descEn: 'Maximum AI usage within regulatory limits' },
|
||||
{ icon: CheckCircle2, color: '#22c55e', de: 'Sichere Kommunikation', en: 'Secure Communication', descDe: 'Chat + Video mit AI Notetaker', descEn: 'Chat + video with AI notetaker' },
|
||||
]
|
||||
|
||||
@@ -58,68 +58,37 @@ export default function ProductSlide({ lang }: ProductSlideProps) {
|
||||
<GlassCard key={idx} delay={0.1 + idx * 0.05} hover className="p-3 text-center">
|
||||
<Icon className="w-5 h-5 mx-auto mb-2" style={{ color: mod.color }} />
|
||||
<p className="text-xs font-bold text-white mb-1">{de ? mod.de : mod.en}</p>
|
||||
<p className="text-[10px] text-white/40 leading-tight">{de ? mod.descDe : mod.descEn}</p>
|
||||
<p className="text-xs text-white/40 leading-tight">{de ? mod.descDe : mod.descEn}</p>
|
||||
</GlassCard>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Pricing + Deployment */}
|
||||
{/* Deployment Options — 2 cards side by side */}
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
{/* Pricing */}
|
||||
<FadeInView delay={0.6}>
|
||||
<GlassCard hover={false} className="p-4">
|
||||
<h3 className="text-xs font-bold text-indigo-400 uppercase tracking-wider mb-3">{i.product.pricingTitle}</h3>
|
||||
<p className="text-[10px] text-white/40 mb-3">{i.product.pricingSubtitle}</p>
|
||||
<div className="space-y-2">
|
||||
{PRICING_TIERS.map((tier, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={`flex justify-between items-center p-2.5 rounded-xl ${
|
||||
tier.highlight ? 'bg-indigo-500/15 border border-indigo-500/30' : 'bg-white/[0.04]'
|
||||
}`}
|
||||
>
|
||||
<div>
|
||||
<span className="text-xs text-white/70 font-medium">{tier.employees}</span>
|
||||
<span className="text-[10px] text-white/40 ml-1">{de ? 'Mitarbeiter' : 'employees'}</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className={`text-xs font-bold ${tier.highlight ? 'text-indigo-300' : 'text-white/70'}`}>
|
||||
{de ? tier.priceDe : tier.priceEn}
|
||||
</span>
|
||||
{tier.noteDe && (
|
||||
<p className="text-[8px] text-white/30">{de ? tier.noteDe : tier.noteEn}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<GlassCard hover={false} className="p-4 h-full border-t-2 border-t-blue-500">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Cloud className="w-5 h-5 text-blue-400" />
|
||||
<h3 className="text-sm font-bold text-blue-400">{i.product.cloud}</h3>
|
||||
</div>
|
||||
<p className="text-sm text-white/50 leading-relaxed mb-3">{i.product.cloudDesc}</p>
|
||||
<div className="flex gap-2">
|
||||
<span className="text-xs bg-blue-500/15 text-blue-300 px-2 py-0.5 rounded-full">BSI DE</span>
|
||||
<span className="text-xs bg-blue-500/15 text-blue-300 px-2 py-0.5 rounded-full">{de ? 'Fix oder flexibel' : 'Fixed or flexible'}</span>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</FadeInView>
|
||||
|
||||
{/* Deployment Options */}
|
||||
<FadeInView delay={0.7}>
|
||||
<GlassCard hover={false} className="p-4">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Cloud className="w-4 h-4 text-blue-400" />
|
||||
<h3 className="text-xs font-bold text-blue-400 uppercase tracking-wider">{i.product.cloud}</h3>
|
||||
</div>
|
||||
<p className="text-[10px] text-white/50 leading-relaxed">{i.product.cloudDesc}</p>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<span className="text-[9px] bg-blue-500/15 text-blue-300 px-2 py-0.5 rounded-full">BSI DE</span>
|
||||
<span className="text-[9px] bg-blue-500/15 text-blue-300 px-2 py-0.5 rounded-full">OVH FR</span>
|
||||
<span className="text-[9px] bg-blue-500/15 text-blue-300 px-2 py-0.5 rounded-full">{de ? 'Fix oder flexibel' : 'Fixed or flexible'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-white/10 pt-3">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<HardDrive className="w-4 h-4 text-white/40" />
|
||||
<h3 className="text-xs font-bold text-white/40 uppercase tracking-wider">{i.product.privacy}</h3>
|
||||
</div>
|
||||
<p className="text-[10px] text-white/40 leading-relaxed">{i.product.privacyDesc}</p>
|
||||
</div>
|
||||
<GlassCard hover={false} className="p-4 h-full border-t-2 border-t-emerald-500">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<HardDrive className="w-5 h-5 text-emerald-400" />
|
||||
<h3 className="text-sm font-bold text-emerald-400">{i.product.privacy}</h3>
|
||||
</div>
|
||||
<p className="text-sm text-white/50 leading-relaxed mb-3">{i.product.privacyDesc}</p>
|
||||
<div className="flex gap-2">
|
||||
<span className="text-xs bg-emerald-500/15 text-emerald-300 px-2 py-0.5 rounded-full">{de ? 'Geplant, optional' : 'Planned, optional'}</span>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</FadeInView>
|
||||
|
||||
@@ -6,74 +6,105 @@ import GradientText from '../ui/GradientText'
|
||||
import FadeInView from '../ui/FadeInView'
|
||||
import GlassCard from '../ui/GlassCard'
|
||||
import KPICard from '../ui/KPICard'
|
||||
import {
|
||||
Shield, Lock, Brain, Globe, Package, Landmark, Heart, ShoppingCart,
|
||||
Activity, Cpu, Bot, Radio, Monitor, Building2, Cog
|
||||
} from 'lucide-react'
|
||||
|
||||
interface RegulatoryLandscapeSlideProps {
|
||||
lang: Language
|
||||
}
|
||||
|
||||
// Regulation categories with their color
|
||||
const CATEGORIES = [
|
||||
{ id: 'privacy', color: '#6366f1', icon: Shield },
|
||||
{ id: 'cyber', color: '#ef4444', icon: Lock },
|
||||
{ id: 'ai', color: '#a855f7', icon: Brain },
|
||||
{ id: 'markets', color: '#22c55e', icon: Globe },
|
||||
{ id: 'product', color: '#f97316', icon: Package },
|
||||
{ id: 'finance', color: '#10b981', icon: Landmark },
|
||||
{ id: 'health', color: '#ec4899', icon: Heart },
|
||||
{ id: 'consumer', color: '#f59e0b', icon: ShoppingCart },
|
||||
// Key EU regulations as columns — the ones investors care about
|
||||
const KEY_REGULATIONS = [
|
||||
{ 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' },
|
||||
]
|
||||
|
||||
// Industry → which categories apply (synced with INDUSTRY_REGULATION_MAP in breakpilot-lehrer)
|
||||
const INDUSTRY_MATRIX: { id: string; icon: typeof Shield; categories: string[]; regCount: number }[] = [
|
||||
{ id: 'allIndustries', icon: Building2, categories: ['privacy'], regCount: 6 },
|
||||
{ id: 'maschinenbau', icon: Cog, categories: ['privacy', 'cyber', 'ai', 'product', 'consumer'], regCount: 15 },
|
||||
{ id: 'health', icon: Heart, categories: ['privacy', 'cyber', 'ai', 'product', 'health'], regCount: 12 },
|
||||
{ id: 'finance', icon: Landmark, categories: ['privacy', 'cyber', 'ai', 'markets', 'finance'], regCount: 15 },
|
||||
{ id: 'ecommerce', icon: ShoppingCart, categories: ['privacy', 'markets', 'product', 'finance', 'consumer'], regCount: 25 },
|
||||
{ id: 'tech', icon: Cpu, categories: ['privacy', 'cyber', 'ai', 'markets'], regCount: 14 },
|
||||
{ id: 'iot', icon: Activity, categories: ['privacy', 'cyber', 'ai', 'product', 'consumer'], regCount: 13 },
|
||||
{ id: 'ai', icon: Bot, categories: ['privacy', 'cyber', 'ai', 'product', 'markets'], regCount: 9 },
|
||||
{ id: 'kritis', icon: Lock, categories: ['privacy', 'cyber', 'ai', 'finance', 'markets'], regCount: 9 },
|
||||
{ id: 'media', icon: Monitor, categories: ['privacy', 'markets', 'ai'], regCount: 9 },
|
||||
{ id: 'public', icon: Radio, categories: ['privacy', 'cyber', 'ai', 'markets', 'health'], regCount: 10 },
|
||||
// 10 real VDMA/VDA/BDI industry sectors with regulation applicability
|
||||
// 380+ documents in RAG pipeline (EU + DACH regulations, standards, rulings)
|
||||
const INDUSTRIES: { id: string; de: string; en: string; regs: string[]; totalDocs: number }[] = [
|
||||
{
|
||||
id: 'automotive',
|
||||
de: 'Automobilindustrie',
|
||||
en: 'Automotive',
|
||||
regs: ['GDPR', 'AI_ACT', 'NIS2', 'CRA', 'MACHINERY_REG', 'DATA_ACT', 'BATTERIE_VO'],
|
||||
totalDocs: 263,
|
||||
},
|
||||
{
|
||||
id: 'maschinenbau',
|
||||
de: 'Maschinen- & Anlagenbau',
|
||||
en: 'Machinery & Plant Eng.',
|
||||
regs: ['GDPR', 'AI_ACT', 'NIS2', 'CRA', 'MACHINERY_REG', 'DATA_ACT'],
|
||||
totalDocs: 266,
|
||||
},
|
||||
{
|
||||
id: 'elektrotechnik',
|
||||
de: 'Elektro- & Digitalindustrie',
|
||||
en: 'Electrical & Digital',
|
||||
regs: ['GDPR', 'AI_ACT', 'NIS2', 'CRA', 'MACHINERY_REG', 'DATA_ACT', 'BATTERIE_VO'],
|
||||
totalDocs: 281,
|
||||
},
|
||||
{
|
||||
id: 'chemie',
|
||||
de: 'Chemie- & Prozessindustrie',
|
||||
en: 'Chemicals & Process',
|
||||
regs: ['GDPR', 'AI_ACT', 'NIS2', 'CRA', 'DATA_ACT'],
|
||||
totalDocs: 250,
|
||||
},
|
||||
{
|
||||
id: 'metall',
|
||||
de: 'Metallindustrie',
|
||||
en: 'Metal Industry',
|
||||
regs: ['GDPR', 'AI_ACT', 'NIS2', 'CRA', 'MACHINERY_REG', 'DATA_ACT'],
|
||||
totalDocs: 246,
|
||||
},
|
||||
{
|
||||
id: 'energie',
|
||||
de: 'Energie & Versorgung',
|
||||
en: 'Energy & Utilities',
|
||||
regs: ['GDPR', 'AI_ACT', 'NIS2', 'CRA', 'DATA_ACT', 'BATTERIE_VO'],
|
||||
totalDocs: 256,
|
||||
},
|
||||
{
|
||||
id: 'transport',
|
||||
de: 'Transport & Logistik',
|
||||
en: 'Transport & Logistics',
|
||||
regs: ['GDPR', 'AI_ACT', 'NIS2', 'CRA', 'DATA_ACT'],
|
||||
totalDocs: 256,
|
||||
},
|
||||
{
|
||||
id: 'handel',
|
||||
de: 'Handel',
|
||||
en: 'Retail & Commerce',
|
||||
regs: ['GDPR', 'AI_ACT', 'NIS2', 'CRA', 'DATA_ACT'],
|
||||
totalDocs: 271,
|
||||
},
|
||||
{
|
||||
id: 'konsumgueter',
|
||||
de: 'Konsumgüter & Lebensmittel',
|
||||
en: 'Consumer Goods & Food',
|
||||
regs: ['GDPR', 'AI_ACT', 'NIS2', 'CRA', 'DATA_ACT', 'BATTERIE_VO'],
|
||||
totalDocs: 265,
|
||||
},
|
||||
{
|
||||
id: 'bau',
|
||||
de: 'Bauwirtschaft',
|
||||
en: 'Construction',
|
||||
regs: ['GDPR', 'AI_ACT', 'NIS2', 'CRA', 'MACHINERY_REG', 'DATA_ACT'],
|
||||
totalDocs: 245,
|
||||
},
|
||||
]
|
||||
|
||||
export default function RegulatoryLandscapeSlide({ lang }: RegulatoryLandscapeSlideProps) {
|
||||
const i = t(lang)
|
||||
const rl = i.regulatoryLandscape
|
||||
|
||||
const categoryLabels: Record<string, string> = {
|
||||
privacy: rl.categoryPrivacy,
|
||||
cyber: rl.categoryCyber,
|
||||
ai: rl.categoryAI,
|
||||
markets: rl.categoryMarkets,
|
||||
product: rl.categoryProduct,
|
||||
finance: rl.categoryFinance,
|
||||
health: rl.categoryHealth,
|
||||
consumer: rl.categoryConsumer,
|
||||
}
|
||||
|
||||
const industryLabels: Record<string, string> = {
|
||||
allIndustries: rl.allIndustries,
|
||||
maschinenbau: rl.maschinenbau,
|
||||
health: rl.health,
|
||||
finance: rl.finance,
|
||||
ecommerce: rl.ecommerce,
|
||||
tech: rl.tech,
|
||||
iot: rl.iot,
|
||||
ai: rl.ai,
|
||||
kritis: rl.kritis,
|
||||
media: rl.media,
|
||||
public: rl.public,
|
||||
}
|
||||
const de = lang === 'de'
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<FadeInView className="text-center mb-6">
|
||||
<FadeInView className="text-center mb-5">
|
||||
<h2 className="text-3xl md:text-5xl font-bold mb-2">
|
||||
<GradientText>{rl.title}</GradientText>
|
||||
</h2>
|
||||
@@ -81,81 +112,92 @@ export default function RegulatoryLandscapeSlide({ lang }: RegulatoryLandscapeSl
|
||||
</FadeInView>
|
||||
|
||||
{/* KPI Row */}
|
||||
<div className="grid grid-cols-3 gap-3 mb-6">
|
||||
<KPICard label={rl.controls} value={25000} suffix="+" color="#6366f1" delay={0.1} />
|
||||
<KPICard label={rl.regulations} value={110} color="#a78bfa" delay={0.2} />
|
||||
<KPICard label={rl.industries} value={10} color="#34d399" delay={0.4} />
|
||||
<div className="grid grid-cols-4 gap-3 mb-5">
|
||||
<KPICard label={de ? 'Gesetze & Dokumente im RAG' : 'Laws & Documents in RAG'} value="380+" color="#6366f1" delay={0.1} />
|
||||
<KPICard label={de ? 'Gelten für alle Branchen' : 'Apply to All Industries'} value={244} color="#a78bfa" delay={0.2} />
|
||||
<KPICard label={de ? 'Branchenspezifische Gesetze' : 'Industry-specific Laws'} value={65} color="#f97316" delay={0.3} />
|
||||
<KPICard label={de ? 'Abgedeckte Branchen' : 'Covered Industries'} value={10} color="#34d399" delay={0.4} />
|
||||
</div>
|
||||
|
||||
{/* Matrix */}
|
||||
<FadeInView delay={0.5}>
|
||||
<GlassCard hover={false} className="p-4 overflow-x-auto">
|
||||
{/* Category Legend */}
|
||||
<div className="flex flex-wrap gap-3 mb-4 justify-center">
|
||||
{CATEGORIES.map((cat) => (
|
||||
<div key={cat.id} className="flex items-center gap-1.5">
|
||||
<div className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: cat.color }} />
|
||||
<span className="text-[10px] text-white/50">{categoryLabels[cat.id]}</span>
|
||||
<div className="space-y-1">
|
||||
{/* Header rows — staggered for space */}
|
||||
<div className="grid items-end gap-1" style={{ gridTemplateColumns: '180px repeat(7, 1fr) 70px' }}>
|
||||
<div className="text-[9px] text-white/70 uppercase tracking-wider pl-1 font-semibold">
|
||||
{de ? 'Branche' : 'Industry'}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Matrix Grid */}
|
||||
<div className="space-y-1.5">
|
||||
{/* Header row */}
|
||||
<div className="grid items-center gap-1" style={{ gridTemplateColumns: '140px repeat(8, 1fr) 50px' }}>
|
||||
<div className="text-[9px] text-white/30 uppercase tracking-wider pl-1">
|
||||
{lang === 'de' ? 'Branche' : 'Industry'}
|
||||
{KEY_REGULATIONS.map((reg, idx) => (
|
||||
<div key={reg.id} className="text-center">
|
||||
{idx % 2 === 0 ? (
|
||||
<span className="text-[8px] font-semibold uppercase tracking-wider" style={{ color: reg.color }}>
|
||||
{reg.label}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
<div className="text-[8px] text-indigo-400 text-center font-semibold uppercase tracking-wider">
|
||||
{de ? 'Gesetze gesamt' : 'Total Laws'}
|
||||
</div>
|
||||
{CATEGORIES.map((cat) => {
|
||||
const CatIcon = cat.icon
|
||||
return (
|
||||
<div key={cat.id} className="flex justify-center">
|
||||
<CatIcon className="w-3.5 h-3.5 opacity-50" style={{ color: cat.color }} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className="text-[9px] text-white/30 text-center">#</div>
|
||||
</div>
|
||||
<div className="grid items-start gap-1" style={{ gridTemplateColumns: '180px repeat(7, 1fr) 70px' }}>
|
||||
<div />
|
||||
{KEY_REGULATIONS.map((reg, idx) => (
|
||||
<div key={reg.id} className="text-center">
|
||||
{idx % 2 === 1 ? (
|
||||
<span className="text-[8px] font-semibold uppercase tracking-wider" style={{ color: reg.color }}>
|
||||
{reg.label}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
<div />
|
||||
</div>
|
||||
|
||||
{/* Industry rows */}
|
||||
{INDUSTRY_MATRIX.map((industry, idx) => {
|
||||
const Icon = industry.icon
|
||||
return (
|
||||
<div
|
||||
key={industry.id}
|
||||
className="grid items-center gap-1 py-1.5 rounded-lg hover:bg-white/[0.04] transition-colors"
|
||||
style={{ gridTemplateColumns: '140px repeat(8, 1fr) 50px' }}
|
||||
>
|
||||
<div className="flex items-center gap-2 pl-1">
|
||||
<Icon className="w-3.5 h-3.5 text-white/40" />
|
||||
<span className="text-[11px] text-white/70 font-medium truncate">
|
||||
{industryLabels[industry.id]}
|
||||
</span>
|
||||
</div>
|
||||
{CATEGORIES.map((cat) => {
|
||||
const applies = industry.categories.includes(cat.id)
|
||||
return (
|
||||
<div key={cat.id} className="flex justify-center">
|
||||
{applies ? (
|
||||
<div
|
||||
className="w-4 h-4 rounded-full flex items-center justify-center"
|
||||
style={{ backgroundColor: `${cat.color}20` }}
|
||||
>
|
||||
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: cat.color }} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-4 h-4 rounded-full bg-white/[0.03]" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className="text-center">
|
||||
<span className="text-xs font-bold text-white/80">{industry.regCount}</span>
|
||||
</div>
|
||||
{INDUSTRIES.map((industry) => (
|
||||
<div
|
||||
key={industry.id}
|
||||
className="grid items-center gap-1 py-1.5 rounded-lg hover:bg-white/[0.04] transition-colors"
|
||||
style={{ gridTemplateColumns: '180px repeat(7, 1fr) 70px' }}
|
||||
>
|
||||
<div className="flex items-center gap-2 pl-1">
|
||||
<span className="text-[11px] text-white/70 font-medium truncate">
|
||||
{de ? industry.de : industry.en}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{KEY_REGULATIONS.map((reg) => {
|
||||
const applies = industry.regs.includes(reg.id)
|
||||
return (
|
||||
<div key={reg.id} className="flex justify-center">
|
||||
{applies ? (
|
||||
<div
|
||||
className="w-4 h-4 rounded-full flex items-center justify-center"
|
||||
style={{ backgroundColor: `${reg.color}20` }}
|
||||
>
|
||||
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: reg.color }} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-4 h-4 rounded-full bg-white/[0.03]" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className="text-center">
|
||||
<span className="text-xs font-bold text-white/80">{industry.totalDocs}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Footer note */}
|
||||
<div className="pt-2 mt-1 border-t border-white/5">
|
||||
<p className="text-xs text-white/50 text-center">
|
||||
{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>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</FadeInView>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState } from 'react'
|
||||
import { Language } from '@/lib/types'
|
||||
import { t } from '@/lib/i18n'
|
||||
import GradientText from '../ui/GradientText'
|
||||
import BrandName from '../ui/BrandName'
|
||||
import FadeInView from '../ui/FadeInView'
|
||||
import GlassCard from '../ui/GlassCard'
|
||||
import { Shield, Scale, Wifi, Lock, Calendar, AlertTriangle, CheckCircle2, Clock } from 'lucide-react'
|
||||
@@ -47,9 +48,9 @@ export default function RegulatorySlide({ lang }: RegulatorySlideProps) {
|
||||
keyRequirements: de
|
||||
? [
|
||||
'Verzeichnis von Verarbeitungstaetigkeiten (VVT)',
|
||||
'Datenschutz-Folgenabschaetzung (DSFA)',
|
||||
'Technische und organisatorische Massnahmen (TOM)',
|
||||
'Betroffenenrechte (Auskunft, Loeschung, Portabilitaet)',
|
||||
'Datenschutz-Folgenabschätzung (DSFA)',
|
||||
'Technische und organisatorische Maßnahmen (TOM)',
|
||||
'Betroffenenrechte (Auskunft, Löschung, Portabilität)',
|
||||
'Auftragsverarbeitungsvertraege (AVV)',
|
||||
'Datenschutzbeauftragter (ab 20 MA)',
|
||||
'Meldepflicht bei Datenpannen (72h)',
|
||||
@@ -67,9 +68,9 @@ export default function RegulatorySlide({ lang }: RegulatorySlideProps) {
|
||||
howWeHelp: de
|
||||
? [
|
||||
'Automatische VVT-Erstellung aus Unternehmensdaten',
|
||||
'KI-gestuetzte DSFA-Durchfuehrung',
|
||||
'KI-gestützte DSFA-Durchführung',
|
||||
'TOM-Generator mit Branchenvorlagen',
|
||||
'Self-Service-Portal fuer Betroffenenanfragen',
|
||||
'Self-Service-Portal für Betroffenenanfragen',
|
||||
'Automatische Dokumentation und Audit-Trail',
|
||||
]
|
||||
: [
|
||||
@@ -85,17 +86,17 @@ export default function RegulatorySlide({ lang }: RegulatorySlideProps) {
|
||||
status: de ? 'Schrittweise ab Aug 2025' : 'Phased from Aug 2025',
|
||||
statusColor: 'text-amber-400',
|
||||
statusIcon: Clock,
|
||||
deadline: de ? 'Aug 2025: Verbote · Aug 2026: Hochrisiko · Aug 2027: Vollstaendig' : 'Aug 2025: Prohibitions · Aug 2026: High-Risk · Aug 2027: Full',
|
||||
deadline: de ? 'Aug 2025: Verbote · Aug 2026: Hochrisiko · Aug 2027: Vollständig' : 'Aug 2025: Prohibitions · Aug 2026: High-Risk · Aug 2027: Full',
|
||||
affectedCompanies: de ? 'Anbieter und Betreiber von KI-Systemen in der EU' : 'Providers and deployers of AI systems in the EU',
|
||||
keyRequirements: de
|
||||
? [
|
||||
'Risikoklassifizierung aller KI-Systeme (Art. 6)',
|
||||
'Konformitaetsbewertung fuer Hochrisiko-KI (Art. 43)',
|
||||
'Konformitätsbewertung für Hochrisiko-KI (Art. 43)',
|
||||
'Technische Dokumentation und Transparenz (Art. 11-13)',
|
||||
'Menschliche Aufsicht (Art. 14)',
|
||||
'Registrierung in EU-Datenbank (Art. 49)',
|
||||
'GPAI-Modell-Pflichten (Art. 51-56)',
|
||||
'Grundrechte-Folgenabschaetzung (Art. 27)',
|
||||
'Grundrechte-Folgenabschätzung (Art. 27)',
|
||||
]
|
||||
: [
|
||||
'Risk classification of all AI systems (Art. 6)',
|
||||
@@ -110,10 +111,10 @@ export default function RegulatorySlide({ lang }: RegulatorySlideProps) {
|
||||
howWeHelp: de
|
||||
? [
|
||||
'Automatische Risikoklassifizierung von KI-Systemen',
|
||||
'Konformitaets-Checklisten mit KI-Unterstuetzung',
|
||||
'Konformitäts-Checklisten mit KI-Unterstützung',
|
||||
'Technische Dokumentation per Template-Engine',
|
||||
'Audit-Vorbereitung fuer Hochrisiko-Systeme',
|
||||
'Monitoring von Rechtsaenderungen',
|
||||
'Audit-Vorbereitung für Hochrisiko-Systeme',
|
||||
'Monitoring von Rechtsänderungen',
|
||||
]
|
||||
: [
|
||||
'Automatic AI system risk classification',
|
||||
@@ -128,17 +129,17 @@ export default function RegulatorySlide({ lang }: RegulatorySlideProps) {
|
||||
status: de ? 'In Kraft seit Dez 2024' : 'In effect since Dec 2024',
|
||||
statusColor: 'text-amber-400',
|
||||
statusIcon: Clock,
|
||||
deadline: de ? 'Sep 2026: Meldepflichten · Dez 2027: Vollstaendig anzuwenden' : 'Sep 2026: Reporting · Dec 2027: Fully applicable',
|
||||
deadline: de ? 'Sep 2026: Meldepflichten · Dez 2027: Vollständig anzuwenden' : 'Sep 2026: Reporting · Dec 2027: Fully applicable',
|
||||
affectedCompanies: de ? 'Alle Hersteller von Produkten mit digitalen Elementen (Hardware & Software)' : 'All manufacturers of products with digital elements (hardware & software)',
|
||||
keyRequirements: de
|
||||
? [
|
||||
'Security by Design fuer alle Produkte mit Software',
|
||||
'Schwachstellen-Management ueber gesamten Produktlebenszyklus',
|
||||
'Software Bill of Materials (SBOM) fuer jedes Produkt',
|
||||
'Kostenlose Sicherheitsupdates fuer Kunden',
|
||||
'Security by Design für alle Produkte mit Software',
|
||||
'Schwachstellen-Management über gesamten Produktlebenszyklus',
|
||||
'Software Bill of Materials (SBOM) für jedes Produkt',
|
||||
'Kostenlose Sicherheitsupdates für Kunden',
|
||||
'Meldepflicht bei aktiv ausgenutzten Schwachstellen (24h)',
|
||||
'Konformitaetsbewertung durch Drittstelle (fuer kritische Produkte)',
|
||||
'CE-Kennzeichnung fuer Cybersecurity-Compliance',
|
||||
'Konformitätsbewertung durch Drittstelle (für kritische Produkte)',
|
||||
'CE-Kennzeichnung für Cybersecurity-Compliance',
|
||||
]
|
||||
: [
|
||||
'Security by design for all products with software',
|
||||
@@ -156,7 +157,7 @@ export default function RegulatorySlide({ lang }: RegulatorySlideProps) {
|
||||
'Kontinuierliches Schwachstellen-Scanning (Trivy, Grype)',
|
||||
'Security-Fixes durch 1000B Cloud-LLM implementiert',
|
||||
'CRA-konforme Dokumentation und Audit-Trail',
|
||||
'Risikoanalysen fuer Embedded-Software und Firmware',
|
||||
'Risikoanalysen für Embedded-Software und Firmware',
|
||||
]
|
||||
: [
|
||||
'Automatic SBOM generation from code repositories',
|
||||
@@ -175,13 +176,13 @@ export default function RegulatorySlide({ lang }: RegulatorySlideProps) {
|
||||
affectedCompanies: de ? '30.000+ Unternehmen in DE (Energie, Transport, Gesundheit, Digital, KRITIS)' : '30,000+ companies in DE (Energy, Transport, Healthcare, Digital, Critical Infrastructure)',
|
||||
keyRequirements: de
|
||||
? [
|
||||
'Risikomanagement-Massnahmen (Art. 21)',
|
||||
'Risikomanagement-Maßnahmen (Art. 21)',
|
||||
'Incident-Meldepflichten: 24h Fruehwarnung, 72h Bericht (Art. 23)',
|
||||
'Business Continuity und Krisenmanagement',
|
||||
'Supply-Chain-Security (Lieferkettenrisiken)',
|
||||
'Geschaeftsleiterhaftung (persoenliche Haftung)',
|
||||
'Registrierung beim BSI',
|
||||
'Regelmaessige Audits und Nachweise',
|
||||
'Regelmäßige Audits und Nachweise',
|
||||
]
|
||||
: [
|
||||
'Risk management measures (Art. 21)',
|
||||
@@ -196,7 +197,7 @@ export default function RegulatorySlide({ lang }: RegulatorySlideProps) {
|
||||
howWeHelp: de
|
||||
? [
|
||||
'Cybersecurity-Policy-Generator nach BSI-Grundschutz',
|
||||
'Incident-Response-Plaene mit KI-Unterstuetzung',
|
||||
'Incident-Response-Pläne mit KI-Unterstützung',
|
||||
'Supply-Chain-Risikoanalyse',
|
||||
'Automatische Audit-Dokumentation',
|
||||
'NIS2-Readiness-Assessment',
|
||||
@@ -237,7 +238,7 @@ export default function RegulatorySlide({ lang }: RegulatorySlideProps) {
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-sm transition-all
|
||||
${activeTab === tab.id
|
||||
? 'bg-indigo-500/20 text-indigo-300 border border-indigo-500/30'
|
||||
: 'bg-white/[0.04] text-white/40 border border-transparent hover:text-white/60 hover:bg-white/[0.06]'
|
||||
: 'bg-white/[0.04] text-white/40 border border-transparent hover:text-white/60 hover:bg-white/[0.06] animate-[pulse_3s_ease-in-out_infinite]'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
@@ -272,8 +273,8 @@ export default function RegulatorySlide({ lang }: RegulatorySlideProps) {
|
||||
</GlassCard>
|
||||
|
||||
<GlassCard hover={false} className="p-4">
|
||||
<p className="text-xs font-semibold text-emerald-400 uppercase tracking-wider mb-2">
|
||||
{de ? 'Wie ComplAI hilft' : 'How ComplAI Helps'}
|
||||
<p className="text-xs font-semibold text-white uppercase tracking-wider mb-2">
|
||||
{de ? <>Wie <BrandName /> hilft</> : <>How <BrandName /> Helps</>}
|
||||
</p>
|
||||
<ul className="space-y-1.5">
|
||||
{reg.howWeHelp.map((item, idx) => (
|
||||
@@ -289,7 +290,7 @@ export default function RegulatorySlide({ lang }: RegulatorySlideProps) {
|
||||
{/* Right: Requirements */}
|
||||
<div className="md:col-span-7">
|
||||
<GlassCard hover={false} className="p-4 h-full">
|
||||
<p className="text-xs font-semibold text-white/40 uppercase tracking-wider mb-3">
|
||||
<p className="text-xs font-semibold text-white uppercase tracking-wider mb-3">
|
||||
{de ? 'Kernanforderungen' : 'Key Requirements'}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
'use client'
|
||||
|
||||
import { Language } from '@/lib/types'
|
||||
import GradientText from '../ui/GradientText'
|
||||
import FadeInView from '../ui/FadeInView'
|
||||
import GlassCard from '../ui/GlassCard'
|
||||
import { AlertTriangle, Shield, Clock, Cpu, Globe, Users, TrendingUp } from 'lucide-react'
|
||||
|
||||
interface RiskSlideProps {
|
||||
lang: Language
|
||||
}
|
||||
|
||||
export default function RiskSlide({ lang }: RiskSlideProps) {
|
||||
const de = lang === 'de'
|
||||
|
||||
const risks = [
|
||||
{
|
||||
icon: Cpu,
|
||||
color: 'text-red-400',
|
||||
border: 'border-red-500/20',
|
||||
risk_de: 'KI-Commoditisierung',
|
||||
risk_en: 'AI Commoditization',
|
||||
desc_de: 'LLMs senken Eintrittsbarrieren — Control-Generierung, DSFA-Erstellung und Policy-Templates werden zur Commodity.',
|
||||
desc_en: 'LLMs lower entry barriers — control generation, DPIA creation and policy templates become commodities.',
|
||||
mitigation_de: 'Wir konkurrieren nicht auf Layer 1 (KI-Wissen), sondern auf Layer 2-6: Integration, Auditierbarkeit, Workflows, EU-Hosting. KI ist unser Multiplikator, nicht unser Produkt.',
|
||||
mitigation_en: 'We don\'t compete on Layer 1 (AI knowledge) but on Layers 2-6: integration, auditability, workflows, EU hosting. AI is our multiplier, not our product.',
|
||||
timeline_de: 'Mittel (3-5 J.)',
|
||||
timeline_en: 'Medium (3-5 yrs)',
|
||||
severity: 'high',
|
||||
},
|
||||
{
|
||||
icon: Globe,
|
||||
color: 'text-amber-400',
|
||||
border: 'border-amber-500/20',
|
||||
risk_de: 'US-Plattform-Expansion',
|
||||
risk_en: 'US Platform Expansion',
|
||||
desc_de: 'Microsoft Purview, Vanta oder Drata expandieren nach Europa mit lokalisiertem Compliance-Angebot.',
|
||||
desc_en: 'Microsoft Purview, Vanta or Drata expand to Europe with localized compliance offerings.',
|
||||
mitigation_de: 'Struktureller Vorteil: 100% EU-Infrastruktur, kein US-SaaS im Stack, Betriebsrat-Fähigkeit. US-Anbieter können den CLOUD Act nicht umgehen — das ist für deutsche Mittelständler ein Ausschlusskriterium.',
|
||||
mitigation_en: 'Structural advantage: 100% EU infrastructure, no US SaaS in the stack, works council compliance. US providers cannot circumvent the CLOUD Act — that is a deal-breaker for German mid-market.',
|
||||
timeline_de: 'Mittel (2-4 J.)',
|
||||
timeline_en: 'Medium (2-4 yrs)',
|
||||
severity: 'medium',
|
||||
},
|
||||
{
|
||||
icon: Users,
|
||||
color: 'text-amber-400',
|
||||
border: 'border-amber-500/20',
|
||||
risk_de: 'Team-Risiko / Key-Person',
|
||||
risk_en: 'Team Risk / Key Person',
|
||||
desc_de: 'Abhängigkeit von zwei Gründern in der Frühphase. Wissensverlust bei Ausfall.',
|
||||
desc_en: 'Dependency on two founders in the early phase. Knowledge loss in case of absence.',
|
||||
mitigation_de: 'Dokumentation aller Prozesse in MkDocs. KI-gestützte Codebasis (500k+ Zeilen mit Tests). ESOP-Pool für Schlüsselmitarbeiter ab Hire 1. Redundanz durch frühe Einstellung eines Rechtsanwalts/Datenschutz.',
|
||||
mitigation_en: 'Documentation of all processes in MkDocs. AI-assisted codebase (500k+ lines with tests). ESOP pool for key employees from hire 1. Redundancy through early hiring of a lawyer/data protection expert.',
|
||||
timeline_de: 'Hoch (Jahr 1-2)',
|
||||
timeline_en: 'High (Year 1-2)',
|
||||
severity: 'medium',
|
||||
},
|
||||
{
|
||||
icon: TrendingUp,
|
||||
color: 'text-blue-400',
|
||||
border: 'border-blue-500/20',
|
||||
risk_de: 'Langsame Kundenakquise',
|
||||
risk_en: 'Slow Customer Acquisition',
|
||||
desc_de: 'B2B-Verkaufszyklen im Mittelstand dauern 3-9 Monate. Compliance-Budgets werden jährlich geplant.',
|
||||
desc_en: 'B2B sales cycles in the mid-market take 3-9 months. Compliance budgets are planned annually.',
|
||||
mitigation_de: 'Beratungsumsätze ab Gründung (5-30k/Mon) überbrücken die Anlaufphase. Channel-Strategie über Bechtle/CANCOM skaliert schneller als Direktvertrieb. Land-and-Expand: Einstieg mit einem Modul, Upsell auf Full Compliance.',
|
||||
mitigation_en: 'Consulting revenue from founding (5-30k/month) bridges the ramp-up. Channel strategy via Bechtle/CANCOM scales faster than direct sales. Land-and-expand: entry with one module, upsell to full compliance.',
|
||||
timeline_de: 'Hoch (Jahr 1-3)',
|
||||
timeline_en: 'High (Year 1-3)',
|
||||
severity: 'medium',
|
||||
},
|
||||
{
|
||||
icon: Shield,
|
||||
color: 'text-emerald-400',
|
||||
border: 'border-emerald-500/20',
|
||||
risk_de: 'Regulatorische Änderungen',
|
||||
risk_en: 'Regulatory Changes',
|
||||
desc_de: 'Neue EU-Gesetze oder Änderungen bestehender Regularien erfordern schnelle Anpassung der Plattform.',
|
||||
desc_en: 'New EU laws or changes to existing regulations require rapid platform adaptation.',
|
||||
mitigation_de: 'Jede regulatorische Änderung vergrößert unseren Markt. RAG-Pipeline kann neue Regularien innerhalb von Tagen indexieren. Über 380 Regularien bereits in der Wissensbasis — Vorsprung von Jahren.',
|
||||
mitigation_en: 'Every regulatory change enlarges our market. RAG pipeline can index new regulations within days. Over 380 regulations already in the knowledge base — years of head start.',
|
||||
timeline_de: 'Laufend',
|
||||
timeline_en: 'Ongoing',
|
||||
severity: 'low',
|
||||
},
|
||||
{
|
||||
icon: Clock,
|
||||
color: 'text-emerald-400',
|
||||
border: 'border-emerald-500/20',
|
||||
risk_de: 'Liquiditätsrisiko',
|
||||
risk_en: 'Liquidity Risk',
|
||||
desc_de: 'Mit 200k Wandeldarlehen ist die Runway begrenzt. Ende 2027 nahe Null.',
|
||||
desc_en: 'With a 200k convertible loan, the runway is limited. Near zero by end of 2027.',
|
||||
mitigation_de: 'Organisches Wachstum durch Beratungsumsätze. Break-Even in 2029. Option auf Pre-Seed BW (L-Bank) verdoppelt die Gesamtfinanzierung auf 400k. Lean-Team mit 9 Personen bis 2030.',
|
||||
mitigation_en: 'Organic growth through consulting revenue. Break-even in 2029. Option for Pre-Seed BW (L-Bank) doubles total funding to 400k. Lean team with 9 people until 2030.',
|
||||
timeline_de: 'Hoch (Jahr 1-2)',
|
||||
timeline_en: 'High (Year 1-2)',
|
||||
severity: 'low',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<FadeInView className="text-center mb-5">
|
||||
<h2 className="text-3xl md:text-4xl font-bold mb-2">
|
||||
<GradientText>{de ? 'Risiken & Mitigation' : 'Risks & Mitigation'}</GradientText>
|
||||
</h2>
|
||||
<p className="text-sm text-white/50 max-w-3xl mx-auto">
|
||||
{de
|
||||
? 'Transparente Darstellung der wesentlichen Risiken und unserer konkreten Gegenmaßnahmen.'
|
||||
: 'Transparent presentation of key risks and our concrete countermeasures.'}
|
||||
</p>
|
||||
</FadeInView>
|
||||
|
||||
<div className="space-y-3">
|
||||
{risks.map((r, idx) => {
|
||||
const Icon = r.icon
|
||||
const severityColor = r.severity === 'high' ? 'bg-red-500/20 text-red-300 border-red-500/30'
|
||||
: r.severity === 'medium' ? 'bg-amber-500/20 text-amber-300 border-amber-500/30'
|
||||
: 'bg-emerald-500/20 text-emerald-300 border-emerald-500/30'
|
||||
|
||||
return (
|
||||
<FadeInView key={idx} delay={0.05 + idx * 0.05}>
|
||||
<GlassCard hover={false} className={`p-4 border-l-2 ${r.border}`}>
|
||||
<div className="grid md:grid-cols-12 gap-3">
|
||||
{/* Risk */}
|
||||
<div className="md:col-span-5">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<Icon className={`w-4 h-4 ${r.color}`} />
|
||||
<h3 className={`text-sm font-bold ${r.color}`}>{de ? r.risk_de : r.risk_en}</h3>
|
||||
<span className={`text-[9px] px-1.5 py-0.5 rounded-full border ${severityColor}`}>
|
||||
{de ? r.timeline_de : r.timeline_en}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-white/40 leading-relaxed">
|
||||
{de ? r.desc_de : r.desc_en}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Arrow */}
|
||||
<div className="hidden md:flex items-center justify-center">
|
||||
<div className="w-6 h-[1px] bg-white/10" />
|
||||
<AlertTriangle className="w-3 h-3 text-white/20 mx-1" />
|
||||
<div className="w-6 h-[1px] bg-white/10" />
|
||||
</div>
|
||||
|
||||
{/* Mitigation */}
|
||||
<div className="md:col-span-6">
|
||||
<p className="text-[10px] text-emerald-400/60 uppercase tracking-wider mb-1 font-semibold">
|
||||
{de ? 'Mitigation' : 'Mitigation'}
|
||||
</p>
|
||||
<p className="text-xs text-white/60 leading-relaxed">
|
||||
{de ? r.mitigation_de : r.mitigation_en}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</FadeInView>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Key Insight */}
|
||||
<FadeInView delay={0.4} className="mt-4">
|
||||
<div className="bg-gradient-to-r from-indigo-500/10 to-purple-500/10 border border-indigo-500/20 rounded-xl px-5 py-3 text-center">
|
||||
<p className="text-sm text-white/70 italic">
|
||||
{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>
|
||||
</div>
|
||||
</FadeInView>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import Image from 'next/image'
|
||||
import { Language } from '@/lib/types'
|
||||
@@ -43,6 +43,26 @@ export default function SDKDemoSlide({ lang }: SDKDemoSlideProps) {
|
||||
const [fullscreen, setFullscreen] = useState(false)
|
||||
const [autoPlay, setAutoPlay] = useState(true)
|
||||
|
||||
// Track which images have actually loaded so we never cross-fade to a blank
|
||||
// frame. While the target image is still fetching, `shown` stays on the
|
||||
// previous loaded one — this eliminates the flash of empty canvas the user
|
||||
// hit on the first pass through the carousel.
|
||||
const loadedRef = useRef<Set<number>>(new Set())
|
||||
const [shown, setShown] = useState(0)
|
||||
|
||||
const handleLoaded = useCallback((idx: number) => {
|
||||
loadedRef.current.add(idx)
|
||||
// If the user is currently waiting on this image, reveal it immediately.
|
||||
// Otherwise the preceding loaded image keeps showing — no blank flash.
|
||||
if (idx === current) setShown(idx)
|
||||
}, [current])
|
||||
|
||||
useEffect(() => {
|
||||
if (loadedRef.current.has(current)) {
|
||||
setShown(current)
|
||||
}
|
||||
}, [current])
|
||||
|
||||
const next = useCallback(() => {
|
||||
setCurrent(i => (i + 1) % SCREENSHOTS.length)
|
||||
}, [])
|
||||
@@ -71,8 +91,8 @@ export default function SDKDemoSlide({ lang }: SDKDemoSlideProps) {
|
||||
</h2>
|
||||
<p className="text-base text-white/50 max-w-2xl mx-auto">
|
||||
{de
|
||||
? 'Echte Screenshots aus dem Compliance SDK — Kundenprojekt: Müller Maschinenbau GmbH'
|
||||
: 'Real screenshots from the Compliance SDK — Customer project: Müller Maschinenbau GmbH'}
|
||||
? 'Echte Screenshots aus dem Compliance SDK — Beispielkunde: Muster Maschinenbau GmbH'
|
||||
: 'Real screenshots from the Compliance SDK — Example customer: Muster Maschinenbau GmbH'}
|
||||
</p>
|
||||
</FadeInView>
|
||||
|
||||
@@ -90,7 +110,7 @@ export default function SDKDemoSlide({ lang }: SDKDemoSlideProps) {
|
||||
</div>
|
||||
<div className="flex-1 ml-3">
|
||||
<div className="bg-white/[0.06] rounded-md px-3 py-1 text-xs text-white/30 font-mono max-w-md">
|
||||
admin.breakpilot.ai/sdk/{shot.file.replace(/^\d+-/, '').replace('.png', '')}
|
||||
admin-dev.breakpilot.ai/sdk/{shot.file.replace(/^\d+-/, '').replace('.png', '')}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@@ -101,25 +121,31 @@ export default function SDKDemoSlide({ lang }: SDKDemoSlideProps) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Screenshot */}
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={current}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<Image
|
||||
src={`/screenshots/${shot.file}`}
|
||||
alt={de ? shot.de : shot.en}
|
||||
width={1920}
|
||||
height={1080}
|
||||
className="w-full h-auto"
|
||||
priority={current < 3}
|
||||
/>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
{/* Screenshot stack — all images mount at once so we can cross-fade
|
||||
between them by toggling opacity. AnimatePresence mode="wait"
|
||||
unmounts before the next mounts, which forces a cold fetch and
|
||||
produces a blank frame; the stack avoids both. */}
|
||||
<div className="relative aspect-[1920/1080] bg-black/40">
|
||||
{SCREENSHOTS.map((s, idx) => (
|
||||
<div
|
||||
key={s.file}
|
||||
className="absolute inset-0 transition-opacity duration-300 ease-out"
|
||||
style={{ opacity: idx === shown ? 1 : 0 }}
|
||||
aria-hidden={idx !== shown}
|
||||
>
|
||||
<Image
|
||||
src={`/screenshots/${s.file}`}
|
||||
alt={de ? s.de : s.en}
|
||||
fill
|
||||
sizes="(max-width: 1024px) 100vw, 1024px"
|
||||
className="object-cover"
|
||||
priority={idx < 3}
|
||||
loading={idx < 3 ? undefined : 'eager'}
|
||||
onLoadingComplete={() => handleLoaded(idx)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation arrows */}
|
||||
|
||||
@@ -19,60 +19,60 @@ export default function SavingsSlide({ lang }: SavingsSlideProps) {
|
||||
color: 'text-indigo-400',
|
||||
bg: 'border-indigo-500/20',
|
||||
name: de ? 'KMU (25 MA)' : 'SME (25 emp.)',
|
||||
desc: de ? '2-3 Apps, 1-2 Produkte mit SW' : '2-3 apps, 1-2 products with SW',
|
||||
desc: de ? '2-3 Anwendungen, 1-2 Produkte mit Software' : '2-3 applications, 1-2 products with software',
|
||||
bp_price: de ? '15.000 EUR/Jahr' : 'EUR 15,000/yr',
|
||||
savings: [
|
||||
{ label: de ? 'Pentests (2-3 Apps × 6.000€)' : 'Pentests (2-3 apps × €6,000)', without: '18.000', with: '5.000', save: '13.000' },
|
||||
{ label: de ? 'Pentests (2-3 Anwendungen × 6.000€)' : 'Pentests (2-3 applications × €6,000)', without: '18.000', with: '5.000', save: '13.000' },
|
||||
{ label: de ? 'CE-SW-Risikobeurteilung (1-2 Produkte)' : 'CE SW risk assessment (1-2 products)', without: '12.000', with: '3.000', save: '9.000' },
|
||||
{ label: de ? 'Ext. Datenschutzbeauftragter' : 'External DPO', without: '6.000', with: '0', save: '6.000' },
|
||||
{ label: de ? 'Compliance-Dokumentation (VVT, TOMs)' : 'Compliance docs (RoPA, TOMs)', without: '8.000', with: '0', save: '8.000' },
|
||||
{ label: de ? 'Entwickler-Produktivität (Shift-Left)' : 'Developer productivity (shift-left)', without: '26.000', with: '10.000', save: '16.000' },
|
||||
{ label: de ? 'Ext. Datenschutzbeauftragter' : 'External DPO', without: '6.000', with: '3.000', save: '3.000' },
|
||||
{ label: de ? 'Compliance-Dokumentation (VVT, TOMs)' : 'Compliance docs (RoPA, TOMs)', without: '8.000', with: '2.000', save: '6.000' },
|
||||
{ label: de ? 'Produktivere Compliance-Arbeitszeit (~50%)' : 'More productive compliance time (~50%)', without: '30.000', with: '15.000', save: '15.000' },
|
||||
{ label: de ? 'Audit-Vorbereitung' : 'Audit preparation', without: '12.000', with: '3.000', save: '9.000' },
|
||||
],
|
||||
totalWithout: '97.750',
|
||||
totalWith: '44.530',
|
||||
totalSave: '53.220',
|
||||
roi: '3,5x',
|
||||
totalWithout: '86.000',
|
||||
totalWith: '31.000',
|
||||
totalSave: '55.000',
|
||||
roi: '3,7x',
|
||||
},
|
||||
{
|
||||
icon: Factory,
|
||||
color: 'text-emerald-400',
|
||||
bg: 'border-emerald-500/20',
|
||||
name: de ? 'Mittelstand (100 MA)' : 'Mid-size (100 emp.)',
|
||||
desc: de ? '5-8 Apps, 3-5 Produkte, MES/ERP' : '5-8 apps, 3-5 products, MES/ERP',
|
||||
desc: de ? '5-8 Anwendungen, 3-5 Produkte, MES/ERP' : '5-8 applications, 3-5 products, MES/ERP',
|
||||
bp_price: de ? '30.000 EUR/Jahr' : 'EUR 30,000/yr',
|
||||
savings: [
|
||||
{ label: de ? 'Pentests (5-8 Apps × 8.000€)' : 'Pentests (5-8 apps × €8,000)', without: '56.000', with: '15.000', save: '41.000' },
|
||||
{ label: de ? 'Pentests (5-8 Anwendungen × 8.000€)' : 'Pentests (5-8 applications × €8,000)', without: '56.000', with: '15.000', save: '41.000' },
|
||||
{ label: de ? 'CE-SW-Risiko (3-5 Produkte × 15.000€)' : 'CE SW risk (3-5 products × €15,000)', without: '60.000', with: '10.000', save: '50.000' },
|
||||
{ label: de ? 'Compliance-Team (0,5 FTE Audit Manager)' : 'Compliance team (0.5 FTE audit manager)', without: '35.000', with: '10.000', save: '25.000' },
|
||||
{ label: de ? 'Produktivere Compliance-Arbeitszeit (~50%)' : 'More productive compliance time (~50%)', without: '70.000', with: '35.000', save: '35.000' },
|
||||
{ label: de ? 'TISAX / ISO 27001 Zertifizierung' : 'TISAX / ISO 27001 certification', without: '25.000', with: '8.000', save: '17.000' },
|
||||
{ label: de ? 'Entwickler-Produktivität (5 Devs)' : 'Developer productivity (5 devs)', without: '130.000', with: '50.000', save: '80.000' },
|
||||
{ label: de ? 'Compliance-Team (0,5 FTE Audit Manager)' : 'Compliance team (0.5 FTE audit manager)', without: '35.000', with: '15.000', save: '20.000' },
|
||||
{ label: de ? 'CRA/NIS2 Compliance-Aufwand' : 'CRA/NIS2 compliance effort', without: '45.000', with: '15.000', save: '30.000' },
|
||||
],
|
||||
totalWithout: '419.500',
|
||||
totalWith: '193.880',
|
||||
totalSave: '225.620',
|
||||
roi: '7,5x',
|
||||
totalWithout: '291.000',
|
||||
totalWith: '98.000',
|
||||
totalSave: '193.000',
|
||||
roi: '6,4x',
|
||||
},
|
||||
{
|
||||
icon: Building,
|
||||
color: 'text-amber-400',
|
||||
bg: 'border-amber-500/20',
|
||||
name: de ? 'Konzern (500+ MA)' : 'Enterprise (500+ emp.)',
|
||||
desc: de ? '15-25 Apps, 10-20 Produkte, SCADA/ICS' : '15-25 apps, 10-20 products, SCADA/ICS',
|
||||
desc: de ? '15-25 Anwendungen, 10-20 Produkte, SCADA/ICS' : '15-25 applications, 10-20 products, SCADA/ICS',
|
||||
bp_price: de ? '50.000 EUR/Jahr' : 'EUR 50,000/yr',
|
||||
savings: [
|
||||
{ label: de ? 'Pentests (15-25 Apps × 10.000€)' : 'Pentests (15-25 apps × €10,000)', without: '200.000', with: '50.000', save: '150.000' },
|
||||
{ label: de ? 'Pentests (15-25 Anwendungen × 10.000€)' : 'Pentests (15-25 applications × €10,000)', without: '200.000', with: '50.000', save: '150.000' },
|
||||
{ label: de ? 'CE-SW-Risiko (10-20 Produkte × 18.000€)' : 'CE SW risk (10-20 products × €18,000)', without: '270.000', with: '50.000', save: '220.000' },
|
||||
{ label: de ? 'Compliance-Abteilung (2-3 FTE)' : 'Compliance department (2-3 FTE)', without: '250.000', with: '100.000', save: '150.000' },
|
||||
{ label: de ? 'Externe Berater (TÜV, DEKRA, Anwälte)' : 'External consultants (TÜV, DEKRA, lawyers)', without: '120.000', with: '30.000', save: '90.000' },
|
||||
{ label: de ? 'Entwickler-Produktivität (20+ Devs)' : 'Developer productivity (20+ devs)', without: '540.000', with: '200.000', save: '340.000' },
|
||||
{ label: de ? 'Produktivere Compliance-Arbeitszeit (~50%)' : 'More productive compliance time (~50%)', without: '200.000', with: '100.000', save: '100.000' },
|
||||
{ label: de ? 'Externe Berater (TÜV, DEKRA, Anwälte)' : 'External consultants (TÜV, DEKRA, lawyers)', without: '120.000', with: '40.000', save: '80.000' },
|
||||
{ label: de ? 'Compliance-Abteilung (2-3 FTE)' : 'Compliance department (2-3 FTE)', without: '250.000', with: '120.000', save: '130.000' },
|
||||
{ label: de ? 'Incident Response / Strafvermeidung' : 'Incident response / penalty avoidance', without: '150.000', with: '50.000', save: '100.000' },
|
||||
],
|
||||
totalWithout: '2.113.500',
|
||||
totalWith: '1.074.080',
|
||||
totalSave: '1.039.420',
|
||||
roi: '20,8x',
|
||||
totalWithout: '1.190.000',
|
||||
totalWith: '410.000',
|
||||
totalSave: '780.000',
|
||||
roi: '15,6x',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -100,7 +100,7 @@ export default function SavingsSlide({ lang }: SavingsSlideProps) {
|
||||
<Icon className={`w-6 h-6 ${co.color}`} />
|
||||
<div>
|
||||
<h3 className={`text-sm font-bold ${co.color}`}>{co.name}</h3>
|
||||
<p className="text-[10px] text-white/40">{co.desc}</p>
|
||||
<p className="text-xs text-white/40">{co.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
@@ -110,7 +110,7 @@ export default function SavingsSlide({ lang }: SavingsSlideProps) {
|
||||
</div>
|
||||
|
||||
{/* Savings table */}
|
||||
<div className="grid grid-cols-[1fr_80px_80px_80px] gap-x-2 text-[10px] text-white/30 uppercase tracking-wider mb-1.5 border-b border-white/10 pb-1">
|
||||
<div className="grid grid-cols-[1fr_80px_80px_80px] gap-x-2 text-xs text-white/30 uppercase tracking-wider mb-1.5 border-b border-white/10 pb-1">
|
||||
<span>{de ? 'Kostenposition' : 'Cost Item'}</span>
|
||||
<span className="text-right">{de ? 'Ohne' : 'Without'}</span>
|
||||
<span className="text-right">{de ? 'Mit' : 'With'}</span>
|
||||
|
||||
@@ -18,6 +18,7 @@ const colors = ['from-blue-500 to-cyan-500', 'from-indigo-500 to-purple-500', 'f
|
||||
|
||||
export default function SolutionSlide({ lang }: SolutionSlideProps) {
|
||||
const i = t(lang)
|
||||
const de = lang === 'de'
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -52,6 +53,27 @@ export default function SolutionSlide({ lang }: SolutionSlideProps) {
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Compliance Optimizer MOAT */}
|
||||
<FadeInView delay={0.8}>
|
||||
<div className="bg-gradient-to-r from-amber-500/10 to-indigo-500/10 border border-amber-500/20 rounded-xl px-6 py-5 mt-8 max-w-4xl mx-auto">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-amber-500 to-orange-600 flex items-center justify-center shrink-0 shadow-lg">
|
||||
<Bot className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-amber-300 mb-1">
|
||||
{de ? 'Compliance Optimizer' : 'Compliance Optimizer'}
|
||||
</h3>
|
||||
<p className="text-sm text-white/50 leading-relaxed">
|
||||
{de
|
||||
? 'Nicht nur „erlaubt/verboten" — unsere Plattform zeigt die maximal zulässige Ausgestaltung jedes KI-Use-Cases. Statt Einschränkung: optimale Ausnutzung des regulatorischen Raums — deterministisch, automatisiert und auditierbar.'
|
||||
: 'Not just "allowed/forbidden" — our platform shows the maximum permissible configuration of every AI use case. Instead of restriction: optimal utilization of the regulatory space — deterministic, automated and auditable.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FadeInView>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,11 +9,98 @@ import { Shield, ScanLine, FileText, Rocket, Users, Building2, Globe, TrendingUp
|
||||
|
||||
interface StrategySlideProps {
|
||||
lang: Language
|
||||
isWandeldarlehen?: boolean
|
||||
}
|
||||
|
||||
export default function StrategySlide({ lang }: StrategySlideProps) {
|
||||
export default function StrategySlide({ lang, isWandeldarlehen }: StrategySlideProps) {
|
||||
const de = lang === 'de'
|
||||
|
||||
const phasesLean = [
|
||||
{
|
||||
icon: Rocket, color: 'text-indigo-400', bg: 'bg-indigo-500/10 border-indigo-500/20',
|
||||
title: de ? 'Phase 1: Gründung' : 'Phase 1: Foundation',
|
||||
period: 'Aug 2026 – Jul 2027',
|
||||
team: de ? '3 Personen' : '3 people',
|
||||
arr: de ? '~60k EUR' : '~EUR 60k',
|
||||
items: de
|
||||
? ['Gründer + Rechtsanwalt/Datenschutz', 'Erste 5-6 Pilotkunden (SaaS)', 'Beratungsumsätze 5-10k/Mon', 'Prototyp → Produktivbetrieb']
|
||||
: ['Founders + lawyer/data protection', 'First 5-6 pilot customers (SaaS)', 'Consulting revenue 5-10k/month', 'Prototype → production'],
|
||||
},
|
||||
{
|
||||
icon: Building2, color: 'text-purple-400', bg: 'bg-purple-500/10 border-purple-500/20',
|
||||
title: de ? 'Phase 2: Produkt' : 'Phase 2: Product',
|
||||
period: 'Aug 2027 – Jul 2028',
|
||||
team: de ? '4-5 Personen' : '4-5 people',
|
||||
arr: de ? '~200k EUR' : '~EUR 200k',
|
||||
items: de
|
||||
? ['Erster Entwickler (Full-Stack)', 'Security Engineer für Scanner-Kern', '8-10 Bestandskunden', 'Beratung steigt auf 20k/Mon']
|
||||
: ['First developer (full-stack)', 'Security engineer for scanner core', '8-10 active customers', 'Consulting grows to 20k/month'],
|
||||
},
|
||||
{
|
||||
icon: Users, color: 'text-emerald-400', bg: 'bg-emerald-500/10 border-emerald-500/20',
|
||||
title: de ? 'Phase 3: Vertrieb' : 'Phase 3: Sales',
|
||||
period: 'Aug 2028 – Jul 2029',
|
||||
team: de ? '5-7 Personen' : '5-7 people',
|
||||
arr: de ? '~500k–1M EUR' : '~EUR 500k–1M',
|
||||
items: de
|
||||
? ['Erste dedizierte Vertriebsperson', 'Backend-Entwickler für Skalierung', '15-30 Bestandskunden', 'Break-Even in Sicht (2029)']
|
||||
: ['First dedicated salesperson', 'Backend developer for scaling', '15-30 active customers', 'Break-even in sight (2029)'],
|
||||
},
|
||||
{
|
||||
icon: Globe, color: 'text-amber-400', bg: 'bg-amber-500/10 border-amber-500/20',
|
||||
title: de ? 'Phase 4: Wachstum' : 'Phase 4: Growth',
|
||||
period: 'Aug 2029 – Dez 2030',
|
||||
team: de ? '7-10 Personen' : '7-10 people',
|
||||
arr: de ? '~2-3M EUR' : '~EUR 2-3M',
|
||||
items: de
|
||||
? ['Customer Success + Marketing', 'DevOps für Infrastruktur', '50-200+ Bestandskunden', 'Profitabel — organisches Wachstum']
|
||||
: ['Customer success + marketing', 'DevOps for infrastructure', '50-200+ active customers', 'Profitable — organic growth'],
|
||||
},
|
||||
]
|
||||
|
||||
const phases1M = [
|
||||
{
|
||||
icon: Rocket, color: 'text-indigo-400', bg: 'bg-indigo-500/10 border-indigo-500/20',
|
||||
title: de ? 'Phase 1: Foundation' : 'Phase 1: Foundation',
|
||||
period: 'Aug 2026 – Jun 2027',
|
||||
team: de ? '5 Mitarbeiter' : '5 employees',
|
||||
arr: '75–150k EUR',
|
||||
items: de
|
||||
? ['Security Engineer + CE-Risikoingenieur als erste Hires', '5 Pilotkunden im Maschinenbau', 'Gründer verkaufen selbst', 'Product-Market Fit beweisen']
|
||||
: ['Security Engineer + CE Risk Engineer as first hires', '5 pilot customers in manufacturing', 'Founders sell themselves', 'Prove product-market fit'],
|
||||
},
|
||||
{
|
||||
icon: Building2, color: 'text-purple-400', bg: 'bg-purple-500/10 border-purple-500/20',
|
||||
title: de ? 'Phase 2: Traction' : 'Phase 2: Traction',
|
||||
period: 'Jul 2027 – Jun 2028',
|
||||
team: de ? '10 Mitarbeiter' : '10 employees',
|
||||
arr: '0,5–1,2M EUR',
|
||||
items: de
|
||||
? ['Channel Manager für Bechtle/CANCOM', 'DevSecOps + KI-Ingenieur', 'Lösungsberater für Partner-Demos', 'Wiederholbarer Vertriebsprozess']
|
||||
: ['Channel Manager for Bechtle/CANCOM', 'DevSecOps + AI engineer', 'Solutions engineer for partner demos', 'Repeatable sales process'],
|
||||
},
|
||||
{
|
||||
icon: Users, color: 'text-emerald-400', bg: 'bg-emerald-500/10 border-emerald-500/20',
|
||||
title: de ? 'Phase 3: Scale' : 'Phase 3: Scale',
|
||||
period: 'Jul 2028 – Jun 2029',
|
||||
team: de ? '17→25 Mitarbeiter' : '17→25 employees',
|
||||
arr: '2–4M EUR',
|
||||
items: de
|
||||
? ['Erster Direktvertrieb neben Channel', 'Compliance-Jurist für Glaubwürdigkeit', 'Security-Analyst / Pentester', 'VP Sales übernimmt vom CEO']
|
||||
: ['First direct sales alongside channel', 'Compliance lawyer for credibility', 'Security analyst / pentester', 'VP Sales takes over from CEO'],
|
||||
},
|
||||
{
|
||||
icon: Globe, color: 'text-amber-400', bg: 'bg-amber-500/10 border-amber-500/20',
|
||||
title: de ? 'Phase 4: Leadership' : 'Phase 4: Leadership',
|
||||
period: 'Jul 2029 – Dez 2030',
|
||||
team: de ? '25→35 Mitarbeiter' : '25→35 employees',
|
||||
arr: '4–10M EUR',
|
||||
items: de
|
||||
? ['EU-Expansion (AT, CH, Benelux)', 'Enterprise-Vertrieb', 'Developer Relations (Snyk-Modell)', 'Break-Even oder Series A']
|
||||
: ['EU expansion (AT, CH, Benelux)', 'Enterprise sales', 'Developer Relations (Snyk model)', 'Break-even or Series A'],
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<FadeInView className="text-center mb-6">
|
||||
@@ -77,48 +164,7 @@ export default function StrategySlide({ lang }: StrategySlideProps) {
|
||||
{de ? 'Firmenaufbau in 4 Phasen' : 'Company Building in 4 Phases'}
|
||||
</h3>
|
||||
<div className="grid md:grid-cols-4 gap-3">
|
||||
{[
|
||||
{
|
||||
icon: Rocket, color: 'text-indigo-400', bg: 'bg-indigo-500/10 border-indigo-500/20',
|
||||
title: de ? 'Phase 1: Foundation' : 'Phase 1: Foundation',
|
||||
period: 'Aug 2026 – Jun 2027',
|
||||
team: de ? '5 Mitarbeiter' : '5 employees',
|
||||
arr: '75–150k EUR',
|
||||
items: de
|
||||
? ['Security Engineer + CE-Risikoingenieur als erste Hires', '5 Pilotkunden im Maschinenbau', 'Gründer verkaufen selbst', 'Product-Market Fit beweisen']
|
||||
: ['Security Engineer + CE Risk Engineer as first hires', '5 pilot customers in manufacturing', 'Founders sell themselves', 'Prove product-market fit'],
|
||||
},
|
||||
{
|
||||
icon: Building2, color: 'text-purple-400', bg: 'bg-purple-500/10 border-purple-500/20',
|
||||
title: de ? 'Phase 2: Traction' : 'Phase 2: Traction',
|
||||
period: 'Jul 2027 – Jun 2028',
|
||||
team: de ? '10 Mitarbeiter' : '10 employees',
|
||||
arr: '0,5–1,2M EUR',
|
||||
items: de
|
||||
? ['Channel Manager für Bechtle/CANCOM', 'DevSecOps + KI-Ingenieur', 'Lösungsberater für Partner-Demos', 'Wiederholbarer Vertriebsprozess']
|
||||
: ['Channel Manager for Bechtle/CANCOM', 'DevSecOps + AI engineer', 'Solutions engineer for partner demos', 'Repeatable sales process'],
|
||||
},
|
||||
{
|
||||
icon: Users, color: 'text-emerald-400', bg: 'bg-emerald-500/10 border-emerald-500/20',
|
||||
title: de ? 'Phase 3: Scale' : 'Phase 3: Scale',
|
||||
period: 'Jul 2028 – Jun 2029',
|
||||
team: de ? '17→25 Mitarbeiter' : '17→25 employees',
|
||||
arr: '2–4M EUR',
|
||||
items: de
|
||||
? ['Erster Direktvertrieb neben Channel', 'Compliance-Jurist für Glaubwürdigkeit', 'Security-Analyst / Pentester', 'VP Sales übernimmt vom CEO']
|
||||
: ['First direct sales alongside channel', 'Compliance lawyer for credibility', 'Security analyst / pentester', 'VP Sales takes over from CEO'],
|
||||
},
|
||||
{
|
||||
icon: Globe, color: 'text-amber-400', bg: 'bg-amber-500/10 border-amber-500/20',
|
||||
title: de ? 'Phase 4: Leadership' : 'Phase 4: Leadership',
|
||||
period: 'Jul 2029 – Dez 2030',
|
||||
team: de ? '25→35 Mitarbeiter' : '25→35 employees',
|
||||
arr: '4–10M EUR',
|
||||
items: de
|
||||
? ['EU-Expansion (AT, CH, Benelux)', 'Enterprise-Vertrieb', 'Developer Relations (Snyk-Modell)', 'Break-Even oder Series A']
|
||||
: ['EU expansion (AT, CH, Benelux)', 'Enterprise sales', 'Developer Relations (Snyk model)', 'Break-even or Series A'],
|
||||
},
|
||||
].map((phase, idx) => {
|
||||
{(isWandeldarlehen ? phasesLean : phases1M).map((phase, idx) => {
|
||||
const Icon = phase.icon
|
||||
return (
|
||||
<GlassCard key={idx} delay={0.25 + idx * 0.05} hover={false} className={`p-3 ${phase.bg} border`}>
|
||||
@@ -126,14 +172,14 @@ export default function StrategySlide({ lang }: StrategySlideProps) {
|
||||
<Icon className={`w-4 h-4 ${phase.color}`} />
|
||||
<h4 className={`text-xs font-bold ${phase.color}`}>{phase.title}</h4>
|
||||
</div>
|
||||
<p className="text-[10px] text-white/30 mb-1">{phase.period}</p>
|
||||
<div className="flex justify-between text-[10px] mb-2">
|
||||
<p className="text-xs text-white/30 mb-1">{phase.period}</p>
|
||||
<div className="flex justify-between text-xs mb-2">
|
||||
<span className="text-white/50">{phase.team}</span>
|
||||
<span className={`font-mono font-bold ${phase.color}`}>{phase.arr}</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{phase.items.map((item, i) => (
|
||||
<p key={i} className="text-[10px] text-white/60 pl-3 relative">
|
||||
<p key={i} className="text-xs text-white/60 pl-3 relative">
|
||||
<span className={`absolute left-0 top-1 w-1.5 h-1.5 rounded-full ${phase.color.replace('text-', 'bg-')}/60`} />
|
||||
{item}
|
||||
</p>
|
||||
@@ -154,9 +200,9 @@ export default function StrategySlide({ lang }: StrategySlideProps) {
|
||||
<GlassCard delay={0.5} hover={false} className="p-4 border-t-2 border-t-blue-500">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="text-sm font-bold text-blue-400">CANCOM Cloud Marketplace</h4>
|
||||
<span className="text-[9px] bg-blue-500/20 text-blue-300 px-2 py-0.5 rounded-full">{de ? 'Schneller Einstieg' : 'Fast Entry'}</span>
|
||||
<span className="text-[11px] bg-blue-500/20 text-blue-300 px-2 py-0.5 rounded-full">{de ? 'Schneller Einstieg' : 'Fast Entry'}</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-white/40 mb-2">{de ? 'TecDAX · ~5.800 MA · 120+ SaaS-Produkte gelistet' : 'TecDAX · ~5,800 emp. · 120+ SaaS products listed'}</p>
|
||||
<p className="text-xs text-white/40 mb-2">{de ? 'TecDAX · ~5.800 MA · 120+ SaaS-Produkte gelistet' : 'TecDAX · ~5,800 emp. · 120+ SaaS products listed'}</p>
|
||||
<div className="space-y-1.5">
|
||||
{(de ? [
|
||||
'Formales ISV-Partnerprogramm — strukturiertes Onboarding',
|
||||
@@ -180,9 +226,9 @@ export default function StrategySlide({ lang }: StrategySlideProps) {
|
||||
<GlassCard delay={0.55} hover={false} className="p-4 border-t-2 border-t-emerald-500">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="text-sm font-bold text-emerald-400">Bechtle Systemhäuser</h4>
|
||||
<span className="text-[9px] bg-emerald-500/20 text-emerald-300 px-2 py-0.5 rounded-full">{de ? 'Größte Reichweite' : 'Largest Reach'}</span>
|
||||
<span className="text-[11px] bg-emerald-500/20 text-emerald-300 px-2 py-0.5 rounded-full">{de ? 'Größte Reichweite' : 'Largest Reach'}</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-white/40 mb-2">{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 className="text-xs text-white/40 mb-2">{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>
|
||||
<div className="space-y-1.5">
|
||||
{(de ? [
|
||||
'Regionaler Einstieg: Lokales Systemhaus wo wir Kunden haben',
|
||||
@@ -203,6 +249,11 @@ export default function StrategySlide({ lang }: StrategySlideProps) {
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
<p className="text-xs text-white/30 text-center mt-2 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>
|
||||
</FadeInView>
|
||||
|
||||
{/* Channel-First Quote */}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { motion } from 'framer-motion'
|
||||
import { Language, PitchTeamMember } from '@/lib/types'
|
||||
import { t } from '@/lib/i18n'
|
||||
import { User, Linkedin } from 'lucide-react'
|
||||
import { User, Linkedin, Github } from 'lucide-react'
|
||||
import GradientText from '../ui/GradientText'
|
||||
import FadeInView from '../ui/FadeInView'
|
||||
import Image from 'next/image'
|
||||
@@ -13,89 +13,109 @@ interface TeamSlideProps {
|
||||
team: PitchTeamMember[]
|
||||
}
|
||||
|
||||
function equityDisplay(pct: number | string | null | undefined): string {
|
||||
const n = Number(pct)
|
||||
if (!Number.isFinite(n)) return '—'
|
||||
return Number.isInteger(n) ? `${n}%` : `${n.toFixed(1)}%`
|
||||
}
|
||||
|
||||
function detectProfileLink(url: string | null | undefined): { icon: typeof Linkedin | typeof Github; label: string } | null {
|
||||
if (!url) return null
|
||||
if (url.includes('github.com')) return { icon: Github, label: 'GitHub' }
|
||||
if (url.includes('linkedin.com')) return { icon: Linkedin, label: 'LinkedIn' }
|
||||
return { icon: Linkedin, label: 'Profile' }
|
||||
}
|
||||
|
||||
export default function TeamSlide({ lang, team }: TeamSlideProps) {
|
||||
const i = t(lang)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<FadeInView className="text-center mb-12">
|
||||
<FadeInView className="text-center mb-8">
|
||||
<h2 className="text-4xl md:text-5xl font-bold mb-3">
|
||||
<GradientText>{i.team.title}</GradientText>
|
||||
</h2>
|
||||
<p className="text-lg text-white/50 max-w-2xl mx-auto">{i.team.subtitle}</p>
|
||||
</FadeInView>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-8 max-w-4xl mx-auto">
|
||||
{team.map((member, idx) => (
|
||||
<motion.div
|
||||
key={member.id}
|
||||
initial={{ opacity: 0, x: idx === 0 ? -40 : 40 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.3 + idx * 0.2, duration: 0.6 }}
|
||||
className="bg-white/[0.08] backdrop-blur-xl border border-white/10 rounded-3xl p-8"
|
||||
>
|
||||
<div className="flex items-start gap-5">
|
||||
{/* Avatar — Foto oder Fallback */}
|
||||
{member.photo_url ? (
|
||||
<div className="w-20 h-20 rounded-2xl overflow-hidden shrink-0 shadow-lg">
|
||||
<Image
|
||||
src={member.photo_url}
|
||||
alt={member.name}
|
||||
width={80}
|
||||
height={80}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-20 h-20 rounded-2xl bg-gradient-to-br from-indigo-500 to-purple-600
|
||||
flex items-center justify-center shrink-0 shadow-lg">
|
||||
<User className="w-10 h-10 text-white" />
|
||||
</div>
|
||||
)}
|
||||
<div className="grid md:grid-cols-2 gap-6 max-w-5xl mx-auto items-stretch">
|
||||
{team.map((member, idx) => {
|
||||
const link = detectProfileLink(member.linkedin_url)
|
||||
const LinkIcon = link?.icon
|
||||
return (
|
||||
<motion.div
|
||||
key={member.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 + idx * 0.15, duration: 0.5 }}
|
||||
className="bg-white/[0.04] backdrop-blur-xl border border-white/[0.08] rounded-2xl p-6 flex flex-col hover:border-indigo-500/20 transition-colors"
|
||||
>
|
||||
{/* Header: avatar + name + role */}
|
||||
<div className="flex items-center gap-4 mb-5">
|
||||
{member.photo_url ? (
|
||||
<div className="w-16 h-16 rounded-2xl overflow-hidden shrink-0 shadow-lg">
|
||||
<Image
|
||||
src={member.photo_url}
|
||||
alt={member.name}
|
||||
width={64}
|
||||
height={64}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center shrink-0 shadow-lg shadow-indigo-500/20">
|
||||
<User className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="text-xl font-bold text-white">{member.name}</h3>
|
||||
{member.linkedin_url && (
|
||||
<a
|
||||
href={member.linkedin_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-1 text-white/30 hover:text-[#0A66C2] transition-colors"
|
||||
title="LinkedIn"
|
||||
>
|
||||
<Linkedin className="w-4 h-4" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-indigo-400 text-sm font-medium mb-3">
|
||||
{lang === 'de' ? member.role_de : member.role_en}
|
||||
</p>
|
||||
<p className="text-sm text-white/50 leading-relaxed mb-4">
|
||||
{lang === 'de' ? member.bio_de : member.bio_en}
|
||||
</p>
|
||||
|
||||
{/* Equity */}
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="text-xs text-white/40">{i.team.equity}:</span>
|
||||
<span className="text-sm font-bold text-white">{member.equity_pct}%</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 mb-0.5 flex-wrap">
|
||||
<h3 className="text-xl font-bold text-white truncate">{member.name}</h3>
|
||||
{link && LinkIcon && (
|
||||
<a
|
||||
href={member.linkedin_url!}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-white/30 hover:text-indigo-300 transition-colors"
|
||||
title={link.label}
|
||||
>
|
||||
<LinkIcon className="w-4 h-4" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-indigo-400 text-sm font-medium">
|
||||
{lang === 'de' ? member.role_de : member.role_en}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Expertise Tags */}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{/* Equity pill in top-right */}
|
||||
<div className="text-right shrink-0">
|
||||
<div className="text-[10px] uppercase tracking-wider text-white/30">{i.team.equity}</div>
|
||||
<div className="text-base font-bold text-white tabular-nums">{equityDisplay(member.equity_pct)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bio */}
|
||||
<p className="text-sm text-white/60 leading-relaxed mb-5 flex-1">
|
||||
{lang === 'de' ? member.bio_de : member.bio_en}
|
||||
</p>
|
||||
|
||||
{/* Expertise tags */}
|
||||
{(member.expertise || []).length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 pt-4 border-t border-white/[0.06]">
|
||||
{(member.expertise || []).map((skill, sidx) => (
|
||||
<span
|
||||
key={sidx}
|
||||
className="text-xs px-2.5 py-1 rounded-full bg-indigo-500/10 text-indigo-300 border border-indigo-500/20"
|
||||
className="text-[11px] px-2.5 py-1 rounded-full bg-indigo-500/10 text-indigo-300 border border-indigo-500/20"
|
||||
>
|
||||
{skill}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
)}
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user