Compare commits
63 Commits
f1710fdb9e
...
coolify
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
375b34a0d8 | ||
|
|
ff9f5e849c | ||
|
|
2fb6b98bc5 | ||
|
|
1f45d6cca8 | ||
|
|
dca0c96f2a | ||
|
|
74927c6f66 | ||
|
|
ddcd89f26d | ||
|
|
5cb91e88d2 | ||
|
|
4ed39d2616 | ||
|
|
ef8284dff5 | ||
|
|
6c883fb12e | ||
|
|
f7b77fd504 | ||
|
|
ff775517a2 | ||
|
|
98a773c7cd | ||
|
|
528abc86ab | ||
|
|
be4d58009a | ||
|
|
e07e1de6c9 | ||
|
|
58e95d5e8e | ||
|
|
786bb409e4 | ||
|
|
3c4f7d900d | ||
|
|
aae07b7a9b | ||
|
|
911d872178 | ||
|
|
fc6a3306d4 | ||
|
|
ab6ba63108 | ||
|
|
769e8c12d5 | ||
|
|
7344e5806e | ||
|
|
32e121f2a3 | ||
|
|
07d470edee | ||
|
|
a84dccb339 | ||
|
|
1a2ae896fb | ||
|
|
d35b0bc78c | ||
|
|
ae008d7d25 | ||
|
|
6658776610 | ||
|
|
d2c94619d8 | ||
|
|
cc1c61947d | ||
|
|
0c2e03f294 | ||
|
|
a638d0e527 | ||
|
|
e613af1a7d | ||
|
|
7107a31496 | ||
|
|
b850368ec9 | ||
|
|
4fa0dd6f6d | ||
|
|
f39c7ca40c | ||
|
|
d571412657 | ||
|
|
10073f3ef0 | ||
|
|
883ef702ac | ||
|
|
4a91814bfc | ||
|
|
482e8574ad | ||
|
|
d9dcfb97ef | ||
|
|
3320ef94fc | ||
|
|
1dfea51919 | ||
|
|
559d7960a2 | ||
|
|
a101426dba | ||
|
|
f6b22820ce | ||
|
|
86588aff09 | ||
|
|
033fa52e5b | ||
|
|
005fb9d219 | ||
|
|
0c01f1c96c | ||
|
|
ffd256d420 | ||
|
|
d542dbbacd | ||
|
|
a3d0024d39 | ||
|
|
998d427c3c | ||
|
|
99f3180ffc | ||
|
|
2ec340c64b |
@@ -1,5 +1,17 @@
|
||||
# BreakPilot Compliance - DSGVO/AI-Act SDK Platform
|
||||
|
||||
> **NON-NEGOTIABLE STRUCTURE RULES** (enforced by `.claude/settings.json` hook, git pre-commit, and CI):
|
||||
> 1. **File-size budget:** soft target **300** lines, **hard cap 500** lines for any non-test, non-generated source file. Anything larger → split it. Exceptions are listed in `.claude/rules/loc-exceptions.txt` and require a written rationale.
|
||||
> 2. **Clean architecture per service.** Routers/handlers stay thin (≤30 lines per handler) and delegate to services; services use repositories; repositories own DB I/O. See `AGENTS.python.md` / `AGENTS.go.md` / `AGENTS.typescript.md`.
|
||||
> 3. **Do not touch the database schema.** No new Alembic migrations, no `ALTER TABLE`, no model field renames without an explicit migration plan reviewed by the DB owner. SQLAlchemy `__tablename__` and column names are frozen.
|
||||
> 4. **Public endpoints are a contract.** Any change to a path, method, status code, request schema, or response schema in `backend-compliance/`, `ai-compliance-sdk/`, `dsms-gateway/`, `document-crawler/`, or `compliance-tts-service/` must be accompanied by a matching update in **every** consumer (`admin-compliance/`, `developer-portal/`, `breakpilot-compliance-sdk/`, `consent-sdk/`). Use the OpenAPI snapshot tests in `tests/contracts/` as the gate.
|
||||
> 5. **Tests are not optional.** New code without tests fails CI. Refactors must preserve coverage and add a characterization test before splitting an oversized file.
|
||||
> 6. **Do not bypass the guardrails.** Do not edit `.claude/settings.json`, `scripts/check-loc.sh`, or the loc-exceptions list to silence violations. If a rule is wrong, raise it in a PR description.
|
||||
>
|
||||
> These rules apply to **every** Claude Code session opened inside this repository, regardless of who launched it. They are loaded automatically via this `CLAUDE.md`.
|
||||
|
||||
|
||||
|
||||
## Entwicklungsumgebung (WICHTIG - IMMER ZUERST LESEN)
|
||||
|
||||
### Zwei-Rechner-Setup + Hetzner
|
||||
|
||||
43
.claude/rules/architecture.md
Normal file
43
.claude/rules/architecture.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# Architecture Rules (auto-loaded)
|
||||
|
||||
These rules apply to **every** Claude Code session in this repository, regardless of who launched it. They are non-negotiable.
|
||||
|
||||
## File-size budget
|
||||
|
||||
- **Soft target:** 300 lines per non-test, non-generated source file.
|
||||
- **Hard cap:** 500 lines. The PreToolUse hook in `.claude/settings.json` blocks Write/Edit operations that would create or push a file past 500. The git pre-commit hook re-checks. CI is the final gate.
|
||||
- Exceptions live in `.claude/rules/loc-exceptions.txt` and require a written rationale plus `[guardrail-change]` in the commit message. The exceptions list should shrink over time, not grow.
|
||||
|
||||
## Clean architecture
|
||||
|
||||
- Python (FastAPI): see `AGENTS.python.md`. Layering: `api → services → repositories → db.models`. Routers ≤30 LOC per handler. Schemas split per domain.
|
||||
- Go (Gin): see `AGENTS.go.md`. Standard Go Project Layout + hexagonal. `cmd/` thin, wiring in `internal/app`.
|
||||
- TypeScript (Next.js): see `AGENTS.typescript.md`. Server-by-default, push the client boundary deep, colocate `_components/` and `_hooks/` per route.
|
||||
|
||||
## Database is frozen
|
||||
|
||||
- No new Alembic migrations. No `ALTER TABLE`. No `__tablename__` or column renames.
|
||||
- The pre-commit hook blocks any change under `migrations/` or `alembic/versions/` unless the commit message contains `[migration-approved]`.
|
||||
|
||||
## Public endpoints are a contract
|
||||
|
||||
- Any change to a path/method/status/request schema/response schema in a backend service must update every consumer in the same change set.
|
||||
- Each backend service has an OpenAPI baseline at `tests/contracts/openapi.baseline.json`. Contract tests fail on drift.
|
||||
|
||||
## Tests
|
||||
|
||||
- New code without tests fails CI.
|
||||
- Refactors must preserve coverage. Before splitting an oversized file, add a characterization test that pins current behavior.
|
||||
- Layout: `tests/unit/`, `tests/integration/`, `tests/contracts/`, `tests/e2e/`.
|
||||
|
||||
## Guardrails are themselves protected
|
||||
|
||||
- Edits to `.claude/settings.json`, `scripts/check-loc.sh`, `scripts/githooks/pre-commit`, `.claude/rules/loc-exceptions.txt`, or any `AGENTS.*.md` require `[guardrail-change]` in the commit message. The pre-commit hook enforces this.
|
||||
- If you (Claude) think a rule is wrong, surface it to the user. Do not silently weaken it.
|
||||
|
||||
## Tooling baseline
|
||||
|
||||
- Python: `ruff`, `mypy --strict` on new modules, `pytest --cov`.
|
||||
- Go: `golangci-lint` strict config, `go vet`, table-driven tests.
|
||||
- TS: `tsc --noEmit` strict, ESLint type-aware, Vitest, Playwright.
|
||||
- All three: dependency caching in CI, license/SBOM scan via `syft`+`grype`.
|
||||
48
.claude/rules/loc-exceptions.txt
Normal file
48
.claude/rules/loc-exceptions.txt
Normal file
@@ -0,0 +1,48 @@
|
||||
# loc-exceptions.txt — files allowed to exceed the 500-line hard cap.
|
||||
#
|
||||
# Format: one repo-relative path per line. Comments start with '#' and are ignored.
|
||||
# Each exception MUST be preceded by a comment explaining why splitting is not viable.
|
||||
#
|
||||
# Phase 0 baseline: this list is initially empty. Phases 1-4 will add grandfathered
|
||||
# entries as we encounter legitimate exceptions (e.g. large generated data tables).
|
||||
# The goal is for this list to SHRINK over time, never grow.
|
||||
|
||||
# --- admin-compliance: static data catalogs (Phase 3) ---
|
||||
# Splitting these would fragment lookup tables without improving readability.
|
||||
admin-compliance/lib/sdk/tom-generator/controls/loader.ts
|
||||
admin-compliance/lib/sdk/vendor-compliance/risk/controls-library.ts
|
||||
admin-compliance/lib/sdk/compliance-scope-triggers.ts
|
||||
admin-compliance/lib/sdk/vendor-compliance/catalog/processing-activities.ts
|
||||
admin-compliance/lib/sdk/catalog-manager/catalog-registry.ts
|
||||
admin-compliance/lib/sdk/dsfa/mitigation-library.ts
|
||||
admin-compliance/lib/sdk/vvt-baseline-catalog.ts
|
||||
admin-compliance/lib/sdk/dsfa/eu-legal-frameworks.ts
|
||||
admin-compliance/lib/sdk/dsfa/risk-catalog.ts
|
||||
admin-compliance/lib/sdk/loeschfristen-baseline-catalog.ts
|
||||
admin-compliance/lib/sdk/vendor-compliance/catalog/vendor-templates.ts
|
||||
admin-compliance/lib/sdk/vendor-compliance/catalog/legal-basis.ts
|
||||
admin-compliance/lib/sdk/vendor-compliance/contract-review/findings.ts
|
||||
admin-compliance/lib/sdk/vendor-compliance/contract-review/checklists.ts
|
||||
admin-compliance/lib/sdk/compliance-scope-types/document-scope-matrix-core.ts
|
||||
admin-compliance/lib/sdk/compliance-scope-types/document-scope-matrix-extended.ts
|
||||
admin-compliance/lib/sdk/demo-data/index.ts
|
||||
admin-compliance/lib/sdk/tom-generator/demo-data/index.ts
|
||||
|
||||
# --- admin-compliance: self-contained export generators (Phase 3) ---
|
||||
# Each file generates a complete document format. Splitting mid-generation
|
||||
# logic would create artificial module boundaries without benefit.
|
||||
admin-compliance/lib/sdk/tom-generator/export/zip.ts
|
||||
admin-compliance/lib/sdk/tom-generator/export/docx.ts
|
||||
admin-compliance/lib/sdk/tom-generator/export/pdf.ts
|
||||
admin-compliance/lib/sdk/einwilligungen/export/pdf.ts
|
||||
admin-compliance/lib/sdk/einwilligungen/generator/privacy-policy-sections.ts
|
||||
|
||||
# --- backend-compliance: legacy utility services (Phase 1) ---
|
||||
# Pre-refactor utility modules not yet split. Phase 5 targets.
|
||||
backend-compliance/compliance/services/control_generator.py
|
||||
backend-compliance/compliance/services/audit_pdf_generator.py
|
||||
backend-compliance/compliance/services/regulation_scraper.py
|
||||
backend-compliance/compliance/services/llm_provider.py
|
||||
backend-compliance/compliance/services/export_generator.py
|
||||
backend-compliance/compliance/services/pdf_extractor.py
|
||||
backend-compliance/compliance/services/ai_compliance_assistant.py
|
||||
28
.claude/settings.json
Normal file
28
.claude/settings.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Write",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "f=$(jq -r '.tool_input.file_path // empty'); [ -z \"$f\" ] && exit 0; lines=$(printf '%s' \"$(jq -r '.tool_input.content // empty')\" | awk 'END{print NR}'); if [ \"${lines:-0}\" -gt 500 ]; then echo '{\"decision\":\"block\",\"reason\":\"breakpilot guardrail: file exceeds the 500-line hard cap. Split it into smaller modules per the layering rules in AGENTS.<lang>.md. If this is generated/data code, add an entry to .claude/rules/loc-exceptions.txt with rationale and reference [guardrail-change].\"}'; exit 0; fi",
|
||||
"shell": "bash",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Edit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "f=$(jq -r '.tool_input.file_path // empty'); [ -z \"$f\" ] || [ ! -f \"$f\" ] && exit 0; case \"$f\" in *.md|*.json|*.yaml|*.yml|*test*|*tests/*|*node_modules/*|*.next/*|*migrations/*) exit 0 ;; esac; new_str=$(jq -r '.tool_input.new_string // empty'); old_str=$(jq -r '.tool_input.old_string // empty'); old_lines=$(printf '%s' \"$old_str\" | awk 'END{print NR}'); new_lines=$(printf '%s' \"$new_str\" | awk 'END{print NR}'); cur=$(wc -l < \"$f\" | tr -d ' '); proj=$((cur - old_lines + new_lines)); if [ \"$proj\" -gt 500 ]; then echo \"{\\\"decision\\\":\\\"block\\\",\\\"reason\\\":\\\"breakpilot guardrail: this edit would push $f to ~$proj lines (hard cap is 500). Split the file before continuing. See AGENTS.<lang>.md for the layering rules.\\\"}\"; fi; exit 0",
|
||||
"shell": "bash",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,55 @@ on:
|
||||
branches: [main, develop]
|
||||
|
||||
jobs:
|
||||
# ========================================
|
||||
# Guardrails — LOC budget + architecture gates
|
||||
# Runs on every push/PR. Fails fast and cheap.
|
||||
# ========================================
|
||||
|
||||
loc-budget:
|
||||
runs-on: docker
|
||||
container: alpine:3.20
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
apk add --no-cache git bash
|
||||
git clone --depth 50 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||
- name: Enforce 500-line hard cap on changed files
|
||||
run: |
|
||||
chmod +x scripts/check-loc.sh
|
||||
if [ "${GITHUB_EVENT_NAME}" = "pull_request" ]; then
|
||||
git fetch origin ${GITHUB_BASE_REF}:base
|
||||
mapfile -t changed < <(git diff --name-only --diff-filter=ACM base...HEAD)
|
||||
[ ${#changed[@]} -eq 0 ] && { echo "No changed files."; exit 0; }
|
||||
scripts/check-loc.sh "${changed[@]}"
|
||||
else
|
||||
# Push to main: only warn on whole-repo state; blocking gate is on PRs.
|
||||
scripts/check-loc.sh || true
|
||||
fi
|
||||
# Phase 0 intentionally gates only changed files so the 205-file legacy
|
||||
# baseline doesn't block every PR. Phases 1-4 drain the baseline; Phase 5
|
||||
# flips this to a whole-repo blocking gate.
|
||||
|
||||
guardrail-integrity:
|
||||
runs-on: docker
|
||||
container: alpine:3.20
|
||||
if: github.event_name == 'pull_request'
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
apk add --no-cache git bash
|
||||
git clone --depth 20 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||
git fetch origin ${GITHUB_BASE_REF}:base
|
||||
- name: Require [guardrail-change] label in PR commits touching guardrails
|
||||
run: |
|
||||
changed=$(git diff --name-only base...HEAD)
|
||||
echo "$changed" | grep -E '^(\.claude/settings\.json|\.claude/rules/loc-exceptions\.txt|scripts/check-loc\.sh|scripts/githooks/pre-commit|AGENTS\.(python|go|typescript)\.md)$' || exit 0
|
||||
if ! git log base..HEAD --format=%B | grep -q '\[guardrail-change\]'; then
|
||||
echo "::error:: Guardrail files were modified but no commit in this PR carries [guardrail-change]."
|
||||
echo "If intentional, amend one commit message with [guardrail-change] and explain why in the body."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ========================================
|
||||
# Lint (nur bei PRs)
|
||||
# ========================================
|
||||
@@ -47,15 +96,28 @@ jobs:
|
||||
run: |
|
||||
apt-get update -qq && apt-get install -y -qq git > /dev/null 2>&1
|
||||
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||
- name: Lint Python services
|
||||
- name: Lint Python services (ruff)
|
||||
run: |
|
||||
pip install --quiet ruff
|
||||
for svc in backend-compliance document-crawler dsms-gateway; do
|
||||
fail=0
|
||||
for svc in backend-compliance document-crawler dsms-gateway compliance-tts-service; do
|
||||
if [ -d "$svc" ]; then
|
||||
echo "=== Linting $svc ==="
|
||||
ruff check "$svc/" --output-format=github || true
|
||||
echo "=== ruff: $svc ==="
|
||||
ruff check "$svc/" --output-format=github || fail=1
|
||||
fi
|
||||
done
|
||||
exit $fail
|
||||
- name: Type-check (mypy via backend-compliance/mypy.ini)
|
||||
# Policy is declared in backend-compliance/mypy.ini: strict mode globally,
|
||||
# with per-module overrides for legacy utility services, the SQLAlchemy
|
||||
# ORM layer, and yet-unrefactored route files. Each Phase 1 Step 4
|
||||
# refactor flips a route file from loose->strict via its own mypy.ini
|
||||
# override block.
|
||||
run: |
|
||||
pip install --quiet mypy
|
||||
if [ -f "backend-compliance/mypy.ini" ]; then
|
||||
cd backend-compliance && mypy compliance/
|
||||
fi
|
||||
|
||||
nodejs-lint:
|
||||
runs-on: docker
|
||||
@@ -66,17 +128,20 @@ jobs:
|
||||
run: |
|
||||
apk add --no-cache git
|
||||
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||
- name: Lint Node.js services
|
||||
- name: Lint + type-check Node.js services
|
||||
run: |
|
||||
fail=0
|
||||
for svc in admin-compliance developer-portal; do
|
||||
if [ -d "$svc" ]; then
|
||||
echo "=== Linting $svc ==="
|
||||
cd "$svc"
|
||||
npm ci --silent 2>/dev/null || npm install --silent
|
||||
npx next lint || true
|
||||
cd ..
|
||||
echo "=== $svc: install ==="
|
||||
(cd "$svc" && (npm ci --silent 2>/dev/null || npm install --silent))
|
||||
echo "=== $svc: next lint ==="
|
||||
(cd "$svc" && npx next lint) || fail=1
|
||||
echo "=== $svc: tsc --noEmit ==="
|
||||
(cd "$svc" && npx tsc --noEmit) || fail=1
|
||||
fi
|
||||
done
|
||||
exit $fail
|
||||
|
||||
# ========================================
|
||||
# Unit Tests
|
||||
@@ -169,6 +234,32 @@ jobs:
|
||||
pip install --quiet --no-cache-dir pytest pytest-asyncio
|
||||
python -m pytest test_main.py -v --tb=short
|
||||
|
||||
# ========================================
|
||||
# SBOM + license scan (compliance product → we eat our own dog food)
|
||||
# ========================================
|
||||
|
||||
sbom-scan:
|
||||
runs-on: docker
|
||||
if: github.event_name == 'pull_request'
|
||||
container: alpine:3.20
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
apk add --no-cache git curl bash
|
||||
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||
- name: Install syft + grype
|
||||
run: |
|
||||
curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin
|
||||
curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b /usr/local/bin
|
||||
- name: Generate SBOM
|
||||
run: |
|
||||
mkdir -p sbom-out
|
||||
syft dir:. -o cyclonedx-json=sbom-out/sbom.cdx.json -q
|
||||
- name: Vulnerability scan (fail on high+)
|
||||
run: |
|
||||
grype sbom:sbom-out/sbom.cdx.json --fail-on high -q || true
|
||||
# Initially non-blocking ('|| true'). Flip to blocking after baseline is clean.
|
||||
|
||||
# ========================================
|
||||
# Validate Canonical Controls
|
||||
# ========================================
|
||||
@@ -194,6 +285,7 @@ jobs:
|
||||
runs-on: docker
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
needs:
|
||||
- loc-budget
|
||||
- test-go-ai-compliance
|
||||
- test-python-backend-compliance
|
||||
- test-python-document-crawler
|
||||
|
||||
126
AGENTS.go.md
Normal file
126
AGENTS.go.md
Normal file
@@ -0,0 +1,126 @@
|
||||
# AGENTS.go.md — Go Service Conventions
|
||||
|
||||
Applies to: `ai-compliance-sdk/`.
|
||||
|
||||
## Layered architecture (Gin)
|
||||
|
||||
Follows [Standard Go Project Layout](https://github.com/golang-standards/project-layout) + hexagonal/clean-arch.
|
||||
|
||||
```
|
||||
ai-compliance-sdk/
|
||||
├── cmd/server/main.go # Thin: parse flags → app.New → app.Run. <50 LOC.
|
||||
├── internal/
|
||||
│ ├── app/ # Wiring: config + DI graph + lifecycle.
|
||||
│ ├── domain/ # Pure types, interfaces, errors. No I/O imports.
|
||||
│ │ └── <aggregate>/
|
||||
│ ├── service/ # Business logic. Depends on domain interfaces only.
|
||||
│ │ └── <aggregate>/
|
||||
│ ├── repository/postgres/ # Concrete repo implementations.
|
||||
│ │ └── <aggregate>/
|
||||
│ ├── transport/http/ # Gin handlers. Thin. One handler per file group.
|
||||
│ │ ├── handler/<aggregate>/
|
||||
│ │ ├── middleware/
|
||||
│ │ └── router.go
|
||||
│ └── platform/ # DB pool, logger, config, tracing.
|
||||
└── pkg/ # Importable by other repos. Empty unless needed.
|
||||
```
|
||||
|
||||
**Dependency direction:** `transport → service → domain ← repository`. `domain` imports nothing from siblings.
|
||||
|
||||
## Handlers
|
||||
|
||||
- One handler = one Gin function. ≤40 LOC.
|
||||
- Bind → call service → map domain error to HTTP via `httperr.Write(c, err)` → respond.
|
||||
- Return early on errors. No business logic, no SQL.
|
||||
|
||||
```go
|
||||
func (h *IACEHandler) Create(c *gin.Context) {
|
||||
var req CreateIACERequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httperr.Write(c, httperr.BadRequest(err))
|
||||
return
|
||||
}
|
||||
out, err := h.svc.Create(c.Request.Context(), req.ToInput())
|
||||
if err != nil {
|
||||
httperr.Write(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, out)
|
||||
}
|
||||
```
|
||||
|
||||
## Services
|
||||
|
||||
- Struct + constructor + interface methods. No package-level state.
|
||||
- Take `context.Context` as first arg always. Propagate to repos.
|
||||
- Return `(value, error)`. Wrap with `fmt.Errorf("create iace: %w", err)`.
|
||||
- Domain errors implemented as sentinel vars or typed errors; matched with `errors.Is` / `errors.As`.
|
||||
|
||||
## Repositories
|
||||
|
||||
- Interface lives in `domain/<aggregate>/repository.go`. Implementation in `repository/postgres/<aggregate>/`.
|
||||
- One file per query group; no file >500 LOC.
|
||||
- Use `pgx`/`sqlc` over hand-rolled string SQL when feasible. No ORM globals.
|
||||
- All queries take `ctx`. No background goroutines without explicit lifecycle.
|
||||
|
||||
## Errors
|
||||
|
||||
Single `internal/platform/httperr` package maps `error` → HTTP status:
|
||||
|
||||
```go
|
||||
switch {
|
||||
case errors.Is(err, domain.ErrNotFound): return 404
|
||||
case errors.Is(err, domain.ErrConflict): return 409
|
||||
case errors.As(err, &validationErr): return 422
|
||||
default: return 500
|
||||
}
|
||||
```
|
||||
|
||||
Never `panic` in request handling. `recover` middleware logs and returns 500.
|
||||
|
||||
## Tests
|
||||
|
||||
- Co-located `*_test.go`.
|
||||
- **Table-driven** tests for service logic; use `t.Run(tt.name, ...)`.
|
||||
- Handlers tested with `httptest.NewRecorder`.
|
||||
- Repos tested with `testcontainers-go` (or the existing compose Postgres) — never mocks at the SQL boundary.
|
||||
- Coverage target: 80% on `service/`. CI fails on regression.
|
||||
|
||||
```go
|
||||
func TestIACEService_Create(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input service.CreateInput
|
||||
setup func(*mockRepo)
|
||||
wantErr error
|
||||
}{
|
||||
{"happy path", validInput(), func(r *mockRepo) { r.createReturns(nil) }, nil},
|
||||
{"conflict", validInput(), func(r *mockRepo) { r.createReturns(domain.ErrConflict) }, domain.ErrConflict},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) { /* ... */ })
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Tooling
|
||||
|
||||
- `golangci-lint` with: `errcheck, govet, staticcheck, revive, gosec, gocyclo (max 15), gocognit (max 20), unused, ineffassign, errorlint, nilerr, nolintlint, contextcheck`.
|
||||
- `gofumpt` formatting.
|
||||
- `go vet ./...` clean.
|
||||
- `go mod tidy` clean — no unused deps.
|
||||
|
||||
## Concurrency
|
||||
|
||||
- Goroutines must have a clear lifecycle owner (struct method that started them must stop them).
|
||||
- Pass `ctx` everywhere. Cancellation respected.
|
||||
- No global mutexes for request data. Use per-request context.
|
||||
|
||||
## What you may NOT do
|
||||
|
||||
- Touch DB schema/migrations.
|
||||
- Add a new top-level package directly under `internal/` without architectural review.
|
||||
- `import "C"`, unsafe, reflection-heavy code.
|
||||
- Use `init()` for non-trivial setup. Wire it in `internal/app`.
|
||||
- Create a file >500 lines.
|
||||
- Change a public route's contract without updating consumers.
|
||||
94
AGENTS.python.md
Normal file
94
AGENTS.python.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# AGENTS.python.md — Python Service Conventions
|
||||
|
||||
Applies to: `backend-compliance/`, `document-crawler/`, `dsms-gateway/`, `compliance-tts-service/`.
|
||||
|
||||
## Layered architecture (FastAPI)
|
||||
|
||||
```
|
||||
compliance/
|
||||
├── api/ # HTTP layer — routers only. Thin (≤30 LOC per handler).
|
||||
│ └── <domain>_routes.py
|
||||
├── services/ # Business logic. Pure-ish; no FastAPI imports.
|
||||
│ └── <domain>_service.py
|
||||
├── repositories/ # DB access. Owns SQLAlchemy session usage.
|
||||
│ └── <domain>_repository.py
|
||||
├── domain/ # Value objects, enums, domain exceptions.
|
||||
├── schemas/ # Pydantic models, split per domain. NEVER one giant schemas.py.
|
||||
│ └── <domain>.py
|
||||
└── db/
|
||||
└── models/ # SQLAlchemy ORM, one module per aggregate. __tablename__ frozen.
|
||||
```
|
||||
|
||||
**Dependency direction:** `api → services → repositories → db.models`. Lower layers must not import upper layers.
|
||||
|
||||
## Routers
|
||||
|
||||
- One `APIRouter` per domain file.
|
||||
- Handlers do exactly: parse request → call service → map domain errors to HTTPException → return response model.
|
||||
- Inject services via `Depends`. No globals.
|
||||
- Tag routes; document with summary + response_model.
|
||||
|
||||
```python
|
||||
@router.post("/dsr/requests", response_model=DSRRequestRead, status_code=201)
|
||||
async def create_dsr_request(
|
||||
payload: DSRRequestCreate,
|
||||
service: DSRService = Depends(get_dsr_service),
|
||||
tenant_id: UUID = Depends(get_tenant_id),
|
||||
) -> DSRRequestRead:
|
||||
try:
|
||||
return await service.create(tenant_id, payload)
|
||||
except DSRConflict as exc:
|
||||
raise HTTPException(409, str(exc)) from exc
|
||||
```
|
||||
|
||||
## Services
|
||||
|
||||
- Constructor takes the repository (interface, not concrete).
|
||||
- No `Request`, `Response`, or HTTP knowledge.
|
||||
- Raise domain exceptions (e.g. `DSRConflict`, `DSRNotFound`), never `HTTPException`.
|
||||
- Return domain objects or Pydantic schemas — pick one and stay consistent inside a service.
|
||||
|
||||
## Repositories
|
||||
|
||||
- Methods are intent-named (`get_pending_for_tenant`), not CRUD-named (`select_where`).
|
||||
- Sessions injected, not constructed inside.
|
||||
- No business logic. No cross-aggregate joins for unrelated workflows — that belongs in a service.
|
||||
- Return ORM models or domain VOs; never `Row`.
|
||||
|
||||
## Schemas (Pydantic v2)
|
||||
|
||||
- One module per domain. Module ≤300 lines.
|
||||
- Use `model_config = ConfigDict(from_attributes=True, frozen=True)` for read models.
|
||||
- Separate `*Create`, `*Update`, `*Read`. No giant union schemas.
|
||||
|
||||
## Tests (`pytest`)
|
||||
|
||||
- Layout: `tests/unit/`, `tests/integration/`, `tests/contracts/`.
|
||||
- Unit tests mock the repository. Use `pytest.fixture` + `unittest.mock.AsyncMock`.
|
||||
- Integration tests run against the real Postgres from `docker-compose.yml` via a transactional fixture (rollback after each test).
|
||||
- Contract tests diff `/openapi.json` against `tests/contracts/openapi.baseline.json`.
|
||||
- Naming: `test_<unit>_<scenario>_<expected>.py::TestClass::test_method`.
|
||||
- `pytest-asyncio` mode = `auto`. Mark slow tests with `@pytest.mark.slow`.
|
||||
- Coverage target: 80% for new code; never decrease the service baseline.
|
||||
|
||||
## Tooling
|
||||
|
||||
- `ruff check` + `ruff format` (line length 100).
|
||||
- `mypy --strict` on `services/`, `repositories/`, `domain/`. Expand outward.
|
||||
- `pip-audit` in CI.
|
||||
- Async-first: prefer `httpx.AsyncClient`, `asyncpg`/`SQLAlchemy 2.x async`.
|
||||
|
||||
## Errors & logging
|
||||
|
||||
- Domain errors inherit from a single `DomainError` base per service.
|
||||
- Log via `structlog` with bound context (`tenant_id`, `request_id`). Never log secrets, PII, or full request bodies.
|
||||
- Audit-relevant actions go through the audit logger, not the application logger.
|
||||
|
||||
## What you may NOT do
|
||||
|
||||
- Add a new Alembic migration.
|
||||
- Rename a `__tablename__`, column, or enum value.
|
||||
- Change a public route's path/method/status/schema without simultaneous dashboard fix.
|
||||
- Catch `Exception` broadly — catch the specific domain or library error.
|
||||
- Put business logic in a router or in a Pydantic validator.
|
||||
- Create a new file >500 lines. Period.
|
||||
85
AGENTS.typescript.md
Normal file
85
AGENTS.typescript.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# AGENTS.typescript.md — TypeScript / Next.js Conventions
|
||||
|
||||
Applies to: `admin-compliance/`, `developer-portal/`, `breakpilot-compliance-sdk/`, `consent-sdk/`, `dsms-node/` (where applicable).
|
||||
|
||||
## Layered architecture (Next.js 15 App Router)
|
||||
|
||||
```
|
||||
app/
|
||||
├── <route>/
|
||||
│ ├── page.tsx # Server Component by default. ≤200 LOC.
|
||||
│ ├── layout.tsx
|
||||
│ ├── _components/ # Private folder; not routable. Colocated UI.
|
||||
│ │ └── <Component>.tsx # Each file ≤300 LOC.
|
||||
│ ├── _hooks/ # Client hooks for this route.
|
||||
│ ├── _server/ # Server actions, data loaders for this route.
|
||||
│ └── loading.tsx / error.tsx
|
||||
├── api/
|
||||
│ └── <domain>/route.ts # Thin handler. Delegates to lib/server/<domain>/.
|
||||
lib/
|
||||
├── <domain>/ # Pure helpers, types, schemas (zod). Reusable.
|
||||
└── server/<domain>/ # Server-only logic; uses "server-only" import.
|
||||
components/ # Truly shared, app-wide components.
|
||||
```
|
||||
|
||||
**Server vs Client:** Default is Server Component. Add `"use client"` only when you need state, effects, or browser APIs. Push the boundary as deep as possible.
|
||||
|
||||
## API routes (route.ts)
|
||||
|
||||
- One handler per HTTP method, ≤40 LOC.
|
||||
- Validate input with `zod`. Reject invalid → 400.
|
||||
- Delegate to `lib/server/<domain>/`. No business logic in `route.ts`.
|
||||
- Always return `NextResponse.json(..., { status })`. Never throw to the framework.
|
||||
|
||||
```ts
|
||||
export async function POST(req: Request) {
|
||||
const parsed = CreateDSRSchema.safeParse(await req.json());
|
||||
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
||||
const result = await dsrService.create(parsed.data);
|
||||
return NextResponse.json(result, { status: 201 });
|
||||
}
|
||||
```
|
||||
|
||||
## Page components
|
||||
|
||||
- Pages >300 lines must be split into colocated `_components/`.
|
||||
- Server Components fetch data; pass plain objects to Client Components.
|
||||
- No data fetching in `useEffect` for server-renderable data.
|
||||
- State management: prefer URL state (`searchParams`) and Server Components over global stores.
|
||||
|
||||
## Types
|
||||
|
||||
- `lib/sdk/types.ts` is being split into `lib/sdk/types/<domain>.ts`. Mirror backend domain boundaries.
|
||||
- All API DTOs are zod schemas; infer types via `z.infer`.
|
||||
- No `any`. No `as unknown as`. If you reach for it, the type is wrong.
|
||||
|
||||
## Tests
|
||||
|
||||
- Unit: **Vitest** (`*.test.ts`/`*.test.tsx`), colocated.
|
||||
- Hooks: `@testing-library/react` `renderHook`.
|
||||
- E2E: **Playwright** (`tests/e2e/`), one spec per top-level page, smoke happy path minimum.
|
||||
- Snapshot tests sparingly — only for stable output (CSV, JSON-LD).
|
||||
- Coverage target: 70% on `lib/`, smoke coverage on `app/`.
|
||||
|
||||
## Tooling
|
||||
|
||||
- `tsc --noEmit` clean (strict mode, `noUncheckedIndexedAccess: true`).
|
||||
- ESLint with `@typescript-eslint`, `eslint-config-next`, type-aware rules on.
|
||||
- `prettier`.
|
||||
- `next build` clean. No `// @ts-ignore`. `// @ts-expect-error` only with a comment explaining why.
|
||||
|
||||
## Performance
|
||||
|
||||
- Use `next/dynamic` for heavy client-only components.
|
||||
- Image: `next/image` with explicit width/height.
|
||||
- Avoid waterfalls — `Promise.all` for parallel data fetches in Server Components.
|
||||
|
||||
## What you may NOT do
|
||||
|
||||
- Put business logic in a `page.tsx` or `route.ts`.
|
||||
- Reach across module boundaries (e.g. `admin-compliance` importing from `developer-portal`).
|
||||
- Use `dangerouslySetInnerHTML` without explicit sanitization.
|
||||
- Call backend APIs directly from Client Components when a Server Component or Server Action would do.
|
||||
- Change a public API route's path/method/schema without updating SDK consumers in the same change.
|
||||
- Create a file >500 lines.
|
||||
- Disable a lint or type rule globally to silence a finding — fix the root cause.
|
||||
51
admin-compliance/README.md
Normal file
51
admin-compliance/README.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# admin-compliance
|
||||
|
||||
Next.js 15 dashboard for BreakPilot Compliance — SDK module UI, company profile, DSR, DSFA, VVT, TOM, consent, AI Act, training, audit, change requests, etc. Also hosts 96+ API routes that proxy/orchestrate backend services.
|
||||
|
||||
**Port:** `3007` (container: `bp-compliance-admin`)
|
||||
**Stack:** Next.js 15 App Router, React 18, TailwindCSS, TypeScript strict.
|
||||
|
||||
## Architecture (target — Phase 3)
|
||||
|
||||
```
|
||||
app/
|
||||
├── <route>/
|
||||
│ ├── page.tsx # Server Component (≤200 LOC)
|
||||
│ ├── _components/ # Colocated UI, each ≤300 LOC
|
||||
│ ├── _hooks/ # Client hooks
|
||||
│ └── _server/ # Server actions
|
||||
├── api/<domain>/route.ts # Thin handlers → lib/server/<domain>/
|
||||
lib/
|
||||
├── <domain>/ # Pure helpers, zod schemas
|
||||
└── server/<domain>/ # "server-only" logic
|
||||
components/ # App-wide shared UI
|
||||
```
|
||||
|
||||
See `../AGENTS.typescript.md`.
|
||||
|
||||
## Run locally
|
||||
|
||||
```bash
|
||||
cd admin-compliance
|
||||
npm install
|
||||
npm run dev # http://localhost:3007
|
||||
```
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
npm test # Vitest unit + component tests
|
||||
npx playwright test # E2E
|
||||
npx tsc --noEmit # Type-check
|
||||
npx next lint
|
||||
```
|
||||
|
||||
## Known debt (Phase 3 targets)
|
||||
|
||||
- `app/sdk/company-profile/page.tsx` (3017 LOC), `tom-generator/controls/loader.ts` (2521), `lib/sdk/types.ts` (2511), `app/sdk/loeschfristen/page.tsx` (2322), `app/sdk/dsb-portal/page.tsx` (2068) — all must be split.
|
||||
- 0 test files for 182 monolithic pages. Phase 3 adds Playwright smoke + Vitest unit coverage.
|
||||
|
||||
## Don't touch
|
||||
|
||||
- Backend API paths without updating `backend-compliance/` in the same change.
|
||||
- `lib/sdk/types.ts` in large contiguous chunks — it's being domain-split.
|
||||
139
admin-compliance/app/sdk/academy/_components/CertificatesTab.tsx
Normal file
139
admin-compliance/app/sdk/academy/_components/CertificatesTab.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import type { Certificate } from '@/lib/sdk/academy/types'
|
||||
|
||||
// =============================================================================
|
||||
// CERTIFICATE ROW
|
||||
// =============================================================================
|
||||
|
||||
function CertificateRow({ cert }: { cert: Certificate }) {
|
||||
const now = new Date()
|
||||
const validUntil = new Date(cert.validUntil)
|
||||
const daysLeft = Math.ceil((validUntil.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
|
||||
const isExpired = daysLeft <= 0
|
||||
const isExpiringSoon = daysLeft > 0 && daysLeft <= 30
|
||||
|
||||
return (
|
||||
<tr className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 font-medium text-gray-900">{cert.userName}</td>
|
||||
<td className="px-4 py-3 text-gray-600">{cert.courseName}</td>
|
||||
<td className="px-4 py-3 text-gray-500">{new Date(cert.issuedAt).toLocaleDateString('de-DE')}</td>
|
||||
<td className="px-4 py-3 text-gray-500">{validUntil.toLocaleDateString('de-DE')}</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
{isExpired ? (
|
||||
<span className="px-2 py-1 text-xs rounded-full bg-red-100 text-red-700">Abgelaufen</span>
|
||||
) : isExpiringSoon ? (
|
||||
<span className="px-2 py-1 text-xs rounded-full bg-orange-100 text-orange-700">Laeuft bald ab</span>
|
||||
) : (
|
||||
<span className="px-2 py-1 text-xs rounded-full bg-green-100 text-green-700">Gueltig</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
{cert.pdfUrl ? (
|
||||
<a
|
||||
href={cert.pdfUrl}
|
||||
download
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="px-3 py-1 text-xs bg-purple-600 text-white rounded hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
PDF Download
|
||||
</a>
|
||||
) : (
|
||||
<span className="px-3 py-1 text-xs bg-gray-100 text-gray-400 rounded cursor-not-allowed">
|
||||
Nicht verfuegbar
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CERTIFICATES TAB
|
||||
// =============================================================================
|
||||
|
||||
export function CertificatesTab({
|
||||
certificates,
|
||||
certSearch,
|
||||
onSearchChange
|
||||
}: {
|
||||
certificates: Certificate[]
|
||||
certSearch: string
|
||||
onSearchChange: (s: string) => void
|
||||
}) {
|
||||
const now = new Date()
|
||||
const total = certificates.length
|
||||
const valid = certificates.filter(c => new Date(c.validUntil) > now).length
|
||||
const expired = certificates.filter(c => new Date(c.validUntil) <= now).length
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-4 text-center">
|
||||
<div className="text-2xl font-bold text-gray-900">{total}</div>
|
||||
<div className="text-sm text-gray-500">Gesamt</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-green-200 p-4 text-center">
|
||||
<div className="text-2xl font-bold text-green-600">{valid}</div>
|
||||
<div className="text-sm text-gray-500">Gueltig</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-red-200 p-4 text-center">
|
||||
<div className="text-2xl font-bold text-red-600">{expired}</div>
|
||||
<div className="text-sm text-gray-500">Abgelaufen</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Nach Mitarbeiter oder Kurs suchen..."
|
||||
value={certSearch}
|
||||
onChange={e => onSearchChange(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
{certificates.length === 0 ? (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
|
||||
<div className="w-16 h-16 mx-auto bg-gray-100 rounded-full flex items-center justify-center mb-4">
|
||||
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4M7.835 4.697a3.42 3.42 0 001.946-.806 3.42 3.42 0 014.438 0 3.42 3.42 0 001.946.806 3.42 3.42 0 013.138 3.138 3.42 3.42 0 00.806 1.946 3.42 3.42 0 010 4.438 3.42 3.42 0 00-.806 1.946 3.42 3.42 0 01-3.138 3.138 3.42 3.42 0 00-1.946.806 3.42 3.42 0 01-4.438 0 3.42 3.42 0 00-1.946-.806 3.42 3.42 0 01-3.138-3.138 3.42 3.42 0 00-.806-1.946 3.42 3.42 0 010-4.438 3.42 3.42 0 00.806-1.946 3.42 3.42 0 013.138-3.138z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Noch keine Zertifikate ausgestellt</h3>
|
||||
<p className="mt-2 text-gray-500">Zertifikate werden automatisch nach Kursabschluss generiert.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-700">Mitarbeiter</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-700">Kurs</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-700">Ausgestellt am</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-700">Gueltig bis</th>
|
||||
<th className="text-center px-4 py-3 font-medium text-gray-700">Status</th>
|
||||
<th className="text-center px-4 py-3 font-medium text-gray-700">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{certificates
|
||||
.filter(c =>
|
||||
!certSearch ||
|
||||
c.userName.toLowerCase().includes(certSearch.toLowerCase()) ||
|
||||
c.courseName.toLowerCase().includes(certSearch.toLowerCase())
|
||||
)
|
||||
.map(cert => <CertificateRow key={cert.id} cert={cert} />)
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
85
admin-compliance/app/sdk/academy/_components/CourseCard.tsx
Normal file
85
admin-compliance/app/sdk/academy/_components/CourseCard.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Course, COURSE_CATEGORY_INFO } from '@/lib/sdk/academy/types'
|
||||
|
||||
export function CourseCard({ course, enrollmentCount, onEdit }: { course: Course; enrollmentCount: number; onEdit?: (course: Course) => void }) {
|
||||
const categoryInfo = COURSE_CATEGORY_INFO[course.category] || COURSE_CATEGORY_INFO['custom']
|
||||
|
||||
return (
|
||||
<div className="relative group">
|
||||
<Link href={`/sdk/academy/${course.id}`}>
|
||||
<div className="bg-white rounded-xl border-2 p-6 hover:shadow-md transition-all cursor-pointer border-gray-200 hover:border-purple-300">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${categoryInfo.bgColor} ${categoryInfo.color}`}>
|
||||
{categoryInfo.label}
|
||||
</span>
|
||||
{course.status === 'published' && (
|
||||
<span className="px-2 py-1 text-xs rounded-full bg-green-100 text-green-700">Veroeffentlicht</span>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 truncate">{course.title}</h3>
|
||||
<p className="text-sm text-gray-500 mt-1 line-clamp-2">{course.description}</p>
|
||||
<div className="mt-3 flex items-center gap-4 text-sm text-gray-500">
|
||||
<span className="flex items-center gap-1">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
{course.lessons.length} Lektionen
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{course.durationMinutes} Min.
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
{enrollmentCount} Teilnehmer
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Bestehensgrenze: {course.passingScore}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right ml-4 text-gray-500">
|
||||
<div className="text-sm font-medium">
|
||||
{course.requiredForRoles.includes('all') ? 'Pflicht fuer alle' : `${course.requiredForRoles.length} Rollen`}
|
||||
</div>
|
||||
<div className="text-xs mt-0.5">
|
||||
{new Date(course.updatedAt).toLocaleDateString('de-DE')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between">
|
||||
<div className="text-sm text-gray-500">
|
||||
Erstellt: {new Date(course.createdAt).toLocaleDateString('de-DE')}
|
||||
</div>
|
||||
<span className="px-3 py-1 text-sm text-purple-600 hover:bg-purple-50 rounded-lg transition-colors">
|
||||
Details
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
{onEdit && (
|
||||
<button
|
||||
onClick={(e) => { e.preventDefault(); onEdit(course) }}
|
||||
className="absolute top-3 right-3 p-1.5 bg-white rounded-lg shadow border border-gray-200 text-gray-400 hover:text-purple-600 hover:border-purple-300 opacity-0 group-hover:opacity-100 transition-all z-10"
|
||||
title="Kurs bearbeiten"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
129
admin-compliance/app/sdk/academy/_components/CourseEditModal.tsx
Normal file
129
admin-compliance/app/sdk/academy/_components/CourseEditModal.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { Course, CourseCategory, COURSE_CATEGORY_INFO } from '@/lib/sdk/academy/types'
|
||||
import { updateCourse } from '@/lib/sdk/academy/api'
|
||||
|
||||
export function CourseEditModal({ course, onClose, onSaved }: {
|
||||
course: Course
|
||||
onClose: () => void
|
||||
onSaved: () => void
|
||||
}) {
|
||||
const [title, setTitle] = useState(course.title)
|
||||
const [description, setDescription] = useState(course.description)
|
||||
const [category, setCategory] = useState<CourseCategory>(course.category)
|
||||
const [durationMinutes, setDurationMinutes] = useState(course.durationMinutes)
|
||||
const [passingScore, setPassingScore] = useState(course.passingScore ?? 70)
|
||||
const [status, setStatus] = useState<'draft' | 'published'>(course.status ?? 'draft')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
await updateCourse(course.id, { title, description, category, durationMinutes, passingScore, status })
|
||||
onSaved()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Speichern')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg">
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Kurs bearbeiten</h2>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Titel</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Kategorie</label>
|
||||
<select
|
||||
value={category}
|
||||
onChange={e => setCategory(e.target.value as CourseCategory)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
>
|
||||
{Object.entries(COURSE_CATEGORY_INFO).map(([key, info]) => (
|
||||
<option key={key} value={key}>{info.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Status</label>
|
||||
<select
|
||||
value={status}
|
||||
onChange={e => setStatus(e.target.value as 'draft' | 'published')}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
>
|
||||
<option value="draft">Entwurf</option>
|
||||
<option value="published">Veroeffentlicht</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Dauer (Minuten)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={durationMinutes}
|
||||
onChange={e => setDurationMinutes(Number(e.target.value))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Bestehensgrenze (%)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={passingScore}
|
||||
onChange={e => setPassingScore(Number(e.target.value))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-3 p-6 border-t border-gray-200">
|
||||
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving || !title}
|
||||
className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{saving ? 'Speichern...' : 'Aenderungen speichern'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
133
admin-compliance/app/sdk/academy/_components/EnrollmentCard.tsx
Normal file
133
admin-compliance/app/sdk/academy/_components/EnrollmentCard.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import {
|
||||
Enrollment,
|
||||
ENROLLMENT_STATUS_INFO,
|
||||
isEnrollmentOverdue,
|
||||
getDaysUntilDeadline
|
||||
} from '@/lib/sdk/academy/types'
|
||||
|
||||
export function EnrollmentCard({ enrollment, courseName, onEdit, onComplete, onDelete }: {
|
||||
enrollment: Enrollment
|
||||
courseName: string
|
||||
onEdit?: (enrollment: Enrollment) => void
|
||||
onComplete?: (id: string) => void
|
||||
onDelete?: (id: string) => void
|
||||
}) {
|
||||
const statusInfo = ENROLLMENT_STATUS_INFO[enrollment.status]
|
||||
const overdue = isEnrollmentOverdue(enrollment)
|
||||
const daysUntil = getDaysUntilDeadline(enrollment.deadline)
|
||||
|
||||
return (
|
||||
<div className={`
|
||||
bg-white rounded-xl border-2 p-6
|
||||
${overdue ? 'border-red-300' :
|
||||
enrollment.status === 'completed' ? 'border-green-200' :
|
||||
enrollment.status === 'in_progress' ? 'border-yellow-200' :
|
||||
'border-gray-200'
|
||||
}
|
||||
`}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Status Badge */}
|
||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${statusInfo.bgColor} ${statusInfo.color}`}>
|
||||
{statusInfo.label}
|
||||
</span>
|
||||
{overdue && (
|
||||
<span className="px-2 py-1 text-xs bg-red-100 text-red-700 rounded-full flex items-center gap-1">
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
Ueberfaellig
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* User Info */}
|
||||
<h3 className="text-lg font-semibold text-gray-900 truncate">
|
||||
{enrollment.userName}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">{enrollment.userEmail}</p>
|
||||
<p className="text-sm text-gray-600 mt-1 font-medium">{courseName}</p>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="mt-3">
|
||||
<div className="flex items-center justify-between text-sm mb-1">
|
||||
<span className="text-gray-500">Fortschritt</span>
|
||||
<span className="font-medium text-gray-700">{enrollment.progress}%</span>
|
||||
</div>
|
||||
<div className="w-full h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${
|
||||
enrollment.progress === 100 ? 'bg-green-500' :
|
||||
overdue ? 'bg-red-500' :
|
||||
'bg-purple-500'
|
||||
}`}
|
||||
style={{ width: `${enrollment.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Side - Deadline */}
|
||||
<div className={`text-right ml-4 ${
|
||||
overdue ? 'text-red-600' :
|
||||
daysUntil <= 7 ? 'text-orange-600' :
|
||||
'text-gray-500'
|
||||
}`}>
|
||||
<div className="text-sm font-medium">
|
||||
{enrollment.status === 'completed'
|
||||
? 'Abgeschlossen'
|
||||
: overdue
|
||||
? `${Math.abs(daysUntil)} Tage ueberfaellig`
|
||||
: `${daysUntil} Tage verbleibend`
|
||||
}
|
||||
</div>
|
||||
<div className="text-xs mt-0.5">
|
||||
Frist: {new Date(enrollment.deadline).toLocaleDateString('de-DE')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between">
|
||||
<div className="text-sm text-gray-500">
|
||||
Gestartet: {new Date(enrollment.startedAt).toLocaleDateString('de-DE')}
|
||||
{enrollment.completedAt && (
|
||||
<span className="ml-3 text-green-600">
|
||||
Abgeschlossen: {new Date(enrollment.completedAt).toLocaleDateString('de-DE')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{enrollment.status === 'in_progress' && onComplete && (
|
||||
<button
|
||||
onClick={() => onComplete(enrollment.id)}
|
||||
className="px-3 py-1 text-xs bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
|
||||
>
|
||||
Abschliessen
|
||||
</button>
|
||||
)}
|
||||
{onEdit && (
|
||||
<button
|
||||
onClick={() => onEdit(enrollment)}
|
||||
className="px-3 py-1 text-xs bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
Bearbeiten
|
||||
</button>
|
||||
)}
|
||||
{onDelete && (
|
||||
<button
|
||||
onClick={() => onDelete(enrollment.id)}
|
||||
className="px-3 py-1 text-xs bg-red-50 text-red-600 rounded-lg hover:bg-red-100 transition-colors"
|
||||
>
|
||||
Loeschen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { Enrollment } from '@/lib/sdk/academy/types'
|
||||
import { updateEnrollment } from '@/lib/sdk/academy/api'
|
||||
|
||||
export function EnrollmentEditModal({ enrollment, onClose, onSaved }: {
|
||||
enrollment: Enrollment
|
||||
onClose: () => void
|
||||
onSaved: () => void
|
||||
}) {
|
||||
const [deadline, setDeadline] = useState(enrollment.deadline ? enrollment.deadline.split('T')[0] : '')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
await updateEnrollment(enrollment.id, { deadline: new Date(deadline).toISOString() })
|
||||
onSaved()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Speichern')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-xl shadow-xl w-full max-w-md">
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Einschreibung bearbeiten</h2>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Teilnehmer: <span className="font-medium text-gray-900">{enrollment.userName}</span></p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Deadline</label>
|
||||
<input
|
||||
type="date"
|
||||
value={deadline}
|
||||
onChange={e => setDeadline(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-3 p-6 border-t border-gray-200">
|
||||
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving || !deadline}
|
||||
className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{saving ? 'Speichern...' : 'Speichern'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
224
admin-compliance/app/sdk/academy/_components/PageSections.tsx
Normal file
224
admin-compliance/app/sdk/academy/_components/PageSections.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import Link from 'next/link'
|
||||
|
||||
// =============================================================================
|
||||
// HEADER ACTIONS
|
||||
// =============================================================================
|
||||
|
||||
export function HeaderActions({
|
||||
isGenerating,
|
||||
onGenerateAll
|
||||
}: {
|
||||
isGenerating: boolean
|
||||
onGenerateAll: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={onGenerateAll}
|
||||
disabled={isGenerating}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isGenerating ? (
|
||||
<svg className="animate-spin w-5 h-5" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
)}
|
||||
{isGenerating ? 'Generiere...' : 'Alle Kurse generieren'}
|
||||
</button>
|
||||
<Link
|
||||
href="/sdk/academy/new"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
Kurs erstellen
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// GENERATION RESULT BAR
|
||||
// =============================================================================
|
||||
|
||||
export function GenerationResultBar({
|
||||
result
|
||||
}: {
|
||||
result: { generated: number; skipped: number; errors: string[] }
|
||||
}) {
|
||||
return (
|
||||
<div className={`p-4 rounded-lg border ${result.errors.length > 0 ? 'bg-yellow-50 border-yellow-200' : 'bg-green-50 border-green-200'}`}>
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<span className="text-green-700 font-medium">{result.generated} Kurse generiert</span>
|
||||
<span className="text-gray-500">{result.skipped} uebersprungen</span>
|
||||
{result.errors.length > 0 && (
|
||||
<span className="text-red-600">{result.errors.length} Fehler</span>
|
||||
)}
|
||||
</div>
|
||||
{result.errors.length > 0 && (
|
||||
<div className="mt-2 text-xs text-red-600">
|
||||
{result.errors.map((err, i) => <div key={i}>{err}</div>)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// LOADING SPINNER
|
||||
// =============================================================================
|
||||
|
||||
export function LoadingSpinner() {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<svg className="animate-spin w-8 h-8 text-purple-600" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// OVERDUE ALERT
|
||||
// =============================================================================
|
||||
|
||||
export function OverdueAlert({ count, onShow }: { count: number; onShow: () => void }) {
|
||||
return (
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl p-4 flex items-center gap-4">
|
||||
<div className="w-10 h-10 bg-red-100 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<svg className="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium text-red-800">
|
||||
Achtung: {count} ueberfaellige Schulung(en)
|
||||
</h4>
|
||||
<p className="text-sm text-red-600">
|
||||
Mitarbeiter haben Pflichtschulungen nicht fristgerecht abgeschlossen. Handeln Sie umgehend.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onShow}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm font-medium"
|
||||
>
|
||||
Anzeigen
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// INFO BOX
|
||||
// =============================================================================
|
||||
|
||||
export function InfoBox() {
|
||||
return (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<svg className="w-5 h-5 text-blue-600 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div>
|
||||
<h4 className="font-medium text-blue-800">Schulungspflicht nach Art. 39 DSGVO</h4>
|
||||
<p className="text-sm text-blue-600 mt-1">
|
||||
Gemaess Art. 39 Abs. 1 lit. b DSGVO gehoert die Sensibilisierung und Schulung
|
||||
der an den Verarbeitungsvorgaengen beteiligten Mitarbeiter zu den Aufgaben des
|
||||
Datenschutzbeauftragten. Nachweisbare Compliance-Schulungen sind Pflicht und
|
||||
sollten mindestens jaehrlich aufgefrischt werden.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// EMPTY STATES
|
||||
// =============================================================================
|
||||
|
||||
export function EmptyCourses({
|
||||
selectedCategory,
|
||||
onClearFilters
|
||||
}: {
|
||||
selectedCategory: string
|
||||
onClearFilters: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
|
||||
<div className="w-16 h-16 mx-auto bg-gray-100 rounded-full flex items-center justify-center mb-4">
|
||||
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Keine Kurse gefunden</h3>
|
||||
<p className="mt-2 text-gray-500">
|
||||
{selectedCategory !== 'all'
|
||||
? 'Passen Sie die Filter an oder'
|
||||
: 'Es sind noch keine Kurse vorhanden.'
|
||||
}
|
||||
</p>
|
||||
{selectedCategory !== 'all' ? (
|
||||
<button
|
||||
onClick={onClearFilters}
|
||||
className="mt-4 px-4 py-2 text-purple-600 hover:bg-purple-50 rounded-lg transition-colors"
|
||||
>
|
||||
Filter zuruecksetzen
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
href="/sdk/academy/new"
|
||||
className="mt-4 inline-flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
Ersten Kurs erstellen
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function EmptyEnrollments({
|
||||
selectedStatus,
|
||||
onClearFilters
|
||||
}: {
|
||||
selectedStatus: string
|
||||
onClearFilters: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
|
||||
<div className="w-16 h-16 mx-auto bg-gray-100 rounded-full flex items-center justify-center mb-4">
|
||||
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Keine Einschreibungen gefunden</h3>
|
||||
<p className="mt-2 text-gray-500">
|
||||
{selectedStatus !== 'all'
|
||||
? 'Passen Sie die Filter an.'
|
||||
: 'Es sind noch keine Mitarbeiter in Kurse eingeschrieben.'
|
||||
}
|
||||
</p>
|
||||
{selectedStatus !== 'all' && (
|
||||
<button
|
||||
onClick={onClearFilters}
|
||||
className="mt-4 px-4 py-2 text-purple-600 hover:bg-purple-50 rounded-lg transition-colors"
|
||||
>
|
||||
Filter zuruecksetzen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
110
admin-compliance/app/sdk/academy/_components/SettingsTab.tsx
Normal file
110
admin-compliance/app/sdk/academy/_components/SettingsTab.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
|
||||
export function SettingsTab({ onSaved, saved }: { onSaved: () => void; saved: boolean }) {
|
||||
const SETTINGS_KEY = 'bp_academy_settings'
|
||||
|
||||
const loadSettings = () => {
|
||||
try {
|
||||
const raw = localStorage.getItem(SETTINGS_KEY)
|
||||
if (raw) return JSON.parse(raw)
|
||||
} catch { /* ignore */ }
|
||||
return {}
|
||||
}
|
||||
|
||||
const defaults = { emailReminders: true, reminderDays: 7, defaultPassingScore: 70, defaultValidityDays: 365 }
|
||||
const saved_settings = loadSettings()
|
||||
const [emailReminders, setEmailReminders] = useState<boolean>(saved_settings.emailReminders ?? defaults.emailReminders)
|
||||
const [reminderDays, setReminderDays] = useState<number>(saved_settings.reminderDays ?? defaults.reminderDays)
|
||||
const [defaultPassingScore, setDefaultPassingScore] = useState<number>(saved_settings.defaultPassingScore ?? defaults.defaultPassingScore)
|
||||
const [defaultValidityDays, setDefaultValidityDays] = useState<number>(saved_settings.defaultValidityDays ?? defaults.defaultValidityDays)
|
||||
|
||||
const handleSave = () => {
|
||||
localStorage.setItem(SETTINGS_KEY, JSON.stringify({ emailReminders, reminderDays, defaultPassingScore, defaultValidityDays }))
|
||||
onSaved()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
{/* Notifications */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-base font-semibold text-gray-900 mb-4">Benachrichtigungen</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-700">E-Mail-Erinnerung bei ueberfaelligen Kursen</div>
|
||||
<div className="text-xs text-gray-500">Mitarbeiter per E-Mail an ausstehende Schulungen erinnern</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setEmailReminders(!emailReminders)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${emailReminders ? 'bg-purple-600' : 'bg-gray-200'}`}
|
||||
>
|
||||
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${emailReminders ? 'translate-x-6' : 'translate-x-1'}`} />
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Tage vor Ablauf erinnern</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={90}
|
||||
value={reminderDays}
|
||||
onChange={e => setReminderDays(Number(e.target.value))}
|
||||
className="w-32 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Course Defaults */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-base font-semibold text-gray-900 mb-4">Standard-Einstellungen fuer neue Kurse</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Standard-Bestehensgrenze (%)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={defaultPassingScore}
|
||||
onChange={e => setDefaultPassingScore(Number(e.target.value))}
|
||||
className="w-32 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Gueltigkeitsdauer (Tage)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={defaultValidityDays}
|
||||
onChange={e => setDefaultValidityDays(Number(e.target.value))}
|
||||
className="w-32 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<svg className="w-5 h-5 text-blue-600 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<p className="text-sm text-blue-700">
|
||||
Zertifikate werden automatisch nach erfolgreichem Kursabschluss generiert. Die Gueltigkeitsdauer gilt ab dem Ausstellungsdatum.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className={`px-6 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
saved ? 'bg-green-600 text-white' : 'bg-purple-600 text-white hover:bg-purple-700'
|
||||
}`}
|
||||
>
|
||||
{saved ? 'Gespeichert ✓' : 'Einstellungen speichern'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
168
admin-compliance/app/sdk/academy/_components/shared.tsx
Normal file
168
admin-compliance/app/sdk/academy/_components/shared.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import {
|
||||
CourseCategory,
|
||||
EnrollmentStatus,
|
||||
COURSE_CATEGORY_INFO,
|
||||
ENROLLMENT_STATUS_INFO
|
||||
} from '@/lib/sdk/academy/types'
|
||||
import { Tab, TabId } from '../_types'
|
||||
|
||||
// =============================================================================
|
||||
// TAB NAVIGATION
|
||||
// =============================================================================
|
||||
|
||||
export function TabNavigation({
|
||||
tabs,
|
||||
activeTab,
|
||||
onTabChange
|
||||
}: {
|
||||
tabs: Tab[]
|
||||
activeTab: TabId
|
||||
onTabChange: (tab: TabId) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="border-b border-gray-200">
|
||||
<nav className="flex gap-1 -mb-px" aria-label="Tabs">
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => onTabChange(tab.id)}
|
||||
className={`
|
||||
px-4 py-3 text-sm font-medium border-b-2 transition-colors
|
||||
${activeTab === tab.id
|
||||
? 'border-purple-600 text-purple-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{tab.label}
|
||||
{tab.count !== undefined && tab.count > 0 && (
|
||||
<span className={`
|
||||
px-2 py-0.5 text-xs rounded-full
|
||||
${tab.countColor || 'bg-gray-100 text-gray-600'}
|
||||
`}>
|
||||
{tab.count}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// STAT CARD
|
||||
// =============================================================================
|
||||
|
||||
export function StatCard({
|
||||
label,
|
||||
value,
|
||||
color = 'gray',
|
||||
icon,
|
||||
trend
|
||||
}: {
|
||||
label: string
|
||||
value: number | string
|
||||
color?: 'gray' | 'blue' | 'yellow' | 'red' | 'green' | 'purple'
|
||||
icon?: React.ReactNode
|
||||
trend?: { value: number; label: string }
|
||||
}) {
|
||||
const colorClasses = {
|
||||
gray: 'border-gray-200 text-gray-900',
|
||||
blue: 'border-blue-200 text-blue-600',
|
||||
yellow: 'border-yellow-200 text-yellow-600',
|
||||
red: 'border-red-200 text-red-600',
|
||||
green: 'border-green-200 text-green-600',
|
||||
purple: 'border-purple-200 text-purple-600'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-xl border ${colorClasses[color]} p-6`}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className={`text-sm ${color === 'gray' ? 'text-gray-500' : `text-${color}-600`}`}>
|
||||
{label}
|
||||
</div>
|
||||
<div className={`text-3xl font-bold mt-1 ${colorClasses[color].split(' ')[1]}`}>
|
||||
{value}
|
||||
</div>
|
||||
{trend && (
|
||||
<div className={`text-xs mt-1 ${trend.value >= 0 ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{trend.value >= 0 ? '+' : ''}{trend.value} {trend.label}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{icon && (
|
||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center bg-${color}-50`}>
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// FILTER BAR
|
||||
// =============================================================================
|
||||
|
||||
export function FilterBar({
|
||||
selectedCategory,
|
||||
selectedStatus,
|
||||
onCategoryChange,
|
||||
onStatusChange,
|
||||
onClear
|
||||
}: {
|
||||
selectedCategory: CourseCategory | 'all'
|
||||
selectedStatus: EnrollmentStatus | 'all'
|
||||
onCategoryChange: (category: CourseCategory | 'all') => void
|
||||
onStatusChange: (status: EnrollmentStatus | 'all') => void
|
||||
onClear: () => void
|
||||
}) {
|
||||
const hasFilters = selectedCategory !== 'all' || selectedStatus !== 'all'
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<span className="text-sm text-gray-500">Filter:</span>
|
||||
|
||||
{/* Category Filter */}
|
||||
<select
|
||||
value={selectedCategory}
|
||||
onChange={(e) => onCategoryChange(e.target.value as CourseCategory | 'all')}
|
||||
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
>
|
||||
<option value="all">Alle Kategorien</option>
|
||||
{Object.entries(COURSE_CATEGORY_INFO).map(([cat, info]) => (
|
||||
<option key={cat} value={cat}>{info.label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Enrollment Status Filter */}
|
||||
<select
|
||||
value={selectedStatus}
|
||||
onChange={(e) => onStatusChange(e.target.value as EnrollmentStatus | 'all')}
|
||||
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
>
|
||||
<option value="all">Alle Status</option>
|
||||
{Object.entries(ENROLLMENT_STATUS_INFO).map(([status, info]) => (
|
||||
<option key={status} value={status}>{info.label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Clear Filters */}
|
||||
{hasFilters && (
|
||||
<button
|
||||
onClick={onClear}
|
||||
className="px-3 py-1.5 text-sm text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
Filter zuruecksetzen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
12
admin-compliance/app/sdk/academy/_types.ts
Normal file
12
admin-compliance/app/sdk/academy/_types.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
export type TabId = 'overview' | 'courses' | 'enrollments' | 'certificates' | 'settings'
|
||||
|
||||
export interface Tab {
|
||||
id: TabId
|
||||
label: string
|
||||
count?: number
|
||||
countColor?: string
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,94 @@
|
||||
'use client'
|
||||
|
||||
import { CompanyProfile, BUSINESS_MODEL_LABELS, COMPANY_SIZE_LABELS, TARGET_MARKET_LABELS } from '@/lib/sdk/types'
|
||||
import { LEGAL_FORM_LABELS } from './constants'
|
||||
|
||||
export function ProfileSummary({
|
||||
formData,
|
||||
onEdit,
|
||||
onContinue,
|
||||
}: {
|
||||
formData: Partial<CompanyProfile>
|
||||
onEdit: () => void
|
||||
onContinue: () => void
|
||||
}) {
|
||||
const summaryItems = [
|
||||
{ label: 'Firmenname', value: formData.companyName },
|
||||
{ label: 'Rechtsform', value: formData.legalForm ? LEGAL_FORM_LABELS[formData.legalForm] : undefined },
|
||||
{ label: 'Branche', value: formData.industry?.join(', ') },
|
||||
{ label: 'Geschaeftsmodell', value: formData.businessModel ? BUSINESS_MODEL_LABELS[formData.businessModel]?.short : undefined },
|
||||
{ label: 'Unternehmensgroesse', value: formData.companySize ? COMPANY_SIZE_LABELS[formData.companySize] : undefined },
|
||||
{ label: 'Mitarbeiter', value: formData.employeeCount },
|
||||
{ label: 'Hauptsitz', value: [formData.headquartersZip, formData.headquartersCity, formData.headquartersCountry === 'DE' ? 'Deutschland' : formData.headquartersCountry].filter(Boolean).join(', ') },
|
||||
{ label: 'Zielmaerkte', value: formData.targetMarkets?.map(m => TARGET_MARKET_LABELS[m] || m).join(', ') },
|
||||
{ label: 'Verantwortlicher', value: formData.isDataController ? 'Ja' : 'Nein' },
|
||||
{ label: 'Auftragsverarbeiter', value: formData.isDataProcessor ? 'Ja' : 'Nein' },
|
||||
{ label: 'DSB', value: formData.dpoName || 'Nicht angegeben' },
|
||||
].filter(item => item.value && item.value.length > 0)
|
||||
|
||||
const missingFields: string[] = []
|
||||
if (!formData.companyName) missingFields.push('Firmenname')
|
||||
if (!formData.legalForm) missingFields.push('Rechtsform')
|
||||
if (!formData.industry || formData.industry.length === 0) missingFields.push('Branche')
|
||||
if (!formData.businessModel) missingFields.push('Geschaeftsmodell')
|
||||
if (!formData.companySize) missingFields.push('Unternehmensgroesse')
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="max-w-4xl mx-auto px-4">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900">Unternehmensprofil</h1>
|
||||
</div>
|
||||
|
||||
{/* Success Banner */}
|
||||
<div className={`rounded-xl border-2 p-6 mb-6 ${formData.isComplete ? 'bg-green-50 border-green-300' : 'bg-yellow-50 border-yellow-300'}`}>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className={`flex-shrink-0 w-12 h-12 rounded-full flex items-center justify-center ${formData.isComplete ? 'bg-green-200' : 'bg-yellow-200'}`}>
|
||||
<span className="text-2xl">{formData.isComplete ? '\u2713' : '!'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className={`text-xl font-bold ${formData.isComplete ? 'text-green-800' : 'text-yellow-800'}`}>
|
||||
{formData.isComplete ? 'Profil erfolgreich abgeschlossen' : 'Profil unvollstaendig'}
|
||||
</h2>
|
||||
<p className={`mt-1 ${formData.isComplete ? 'text-green-700' : 'text-yellow-700'}`}>
|
||||
{formData.isComplete
|
||||
? 'Alle Angaben wurden gespeichert. Sie koennen jetzt mit der Scope-Analyse fortfahren.'
|
||||
: `Es fehlen noch Angaben: ${missingFields.join(', ')}.`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Profile Summary */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6 mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Zusammenfassung</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{summaryItems.map(item => (
|
||||
<div key={item.label} className="flex flex-col">
|
||||
<span className="text-xs font-medium text-gray-500 uppercase tracking-wide">{item.label}</span>
|
||||
<span className="text-sm text-gray-900 mt-0.5">{item.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-between items-center">
|
||||
<button onClick={onEdit} className="px-6 py-3 text-gray-600 hover:text-gray-900 border border-gray-300 rounded-lg hover:bg-gray-50">
|
||||
Profil bearbeiten
|
||||
</button>
|
||||
|
||||
{formData.isComplete ? (
|
||||
<button onClick={onContinue} className="px-8 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 font-medium">
|
||||
Weiter zu Scope
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={onEdit} className="px-8 py-3 bg-yellow-600 text-white rounded-lg hover:bg-yellow-700 font-medium">
|
||||
Fehlende Angaben ergaenzen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { CompanyProfile } from '@/lib/sdk/types'
|
||||
import { AISystem, AISystemTemplate } from './types'
|
||||
import { AI_SYSTEM_TEMPLATES } from './ai-system-data'
|
||||
|
||||
export function StepAISystems({
|
||||
data,
|
||||
onChange,
|
||||
}: {
|
||||
data: Partial<CompanyProfile> & { aiSystems?: AISystem[] }
|
||||
onChange: (updates: Record<string, unknown>) => void
|
||||
}) {
|
||||
const aiSystems: AISystem[] = (data as any).aiSystems || []
|
||||
const [expandedSystem, setExpandedSystem] = useState<string | null>(null)
|
||||
const [collapsedCategories, setCollapsedCategories] = useState<Set<string>>(new Set())
|
||||
|
||||
const activeIds = new Set(aiSystems.map(a => a.id))
|
||||
|
||||
const toggleTemplateSystem = (template: AISystemTemplate) => {
|
||||
if (activeIds.has(template.id)) {
|
||||
onChange({ aiSystems: aiSystems.filter(a => a.id !== template.id) })
|
||||
if (expandedSystem === template.id) setExpandedSystem(null)
|
||||
} else {
|
||||
const newSystem: AISystem = {
|
||||
id: template.id, name: template.name, vendor: template.vendor,
|
||||
purpose: template.typicalPurposes.join(', '), purposes: [],
|
||||
processes_personal_data: template.processes_personal_data_likely, isCustom: false,
|
||||
}
|
||||
onChange({ aiSystems: [...aiSystems, newSystem] })
|
||||
setExpandedSystem(template.id)
|
||||
}
|
||||
}
|
||||
|
||||
const updateAISystem = (id: string, updates: Partial<AISystem>) => {
|
||||
onChange({ aiSystems: aiSystems.map(a => a.id === id ? { ...a, ...updates } : a) })
|
||||
}
|
||||
|
||||
const togglePurpose = (systemId: string, purpose: string) => {
|
||||
const system = aiSystems.find(a => a.id === systemId)
|
||||
if (!system) return
|
||||
const purposes = system.purposes || []
|
||||
const updated = purposes.includes(purpose) ? purposes.filter(p => p !== purpose) : [...purposes, purpose]
|
||||
updateAISystem(systemId, { purposes: updated, purpose: updated.join(', ') })
|
||||
}
|
||||
|
||||
const addCustomSystem = () => {
|
||||
const id = `custom_ai_${Date.now()}`
|
||||
onChange({ aiSystems: [...aiSystems, { id, name: '', vendor: '', purpose: '', processes_personal_data: false, isCustom: true }] })
|
||||
setExpandedSystem(id)
|
||||
}
|
||||
|
||||
const removeSystem = (id: string) => {
|
||||
onChange({ aiSystems: aiSystems.filter(a => a.id !== id) })
|
||||
if (expandedSystem === id) setExpandedSystem(null)
|
||||
}
|
||||
|
||||
const toggleCategoryCollapse = (category: string) => {
|
||||
setCollapsedCategories(prev => { const next = new Set(prev); if (next.has(category)) next.delete(category); else next.add(category); return next })
|
||||
}
|
||||
|
||||
const categoryActiveCount = (systems: AISystemTemplate[]) => systems.filter(s => activeIds.has(s.id)).length
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-1">KI-Systeme im Einsatz</h3>
|
||||
<p className="text-xs text-gray-500 mb-4">
|
||||
Waehlen Sie die KI-Systeme aus, die in Ihrem Unternehmen eingesetzt werden. Dies dient der Erfassung fuer den EU AI Act und die DSGVO-Dokumentation.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{AI_SYSTEM_TEMPLATES.map(group => {
|
||||
const isCollapsed = collapsedCategories.has(group.category)
|
||||
const activeCount = categoryActiveCount(group.systems)
|
||||
|
||||
return (
|
||||
<div key={group.category} className="border border-gray-200 rounded-lg overflow-hidden">
|
||||
<button type="button" onClick={() => toggleCategoryCollapse(group.category)} className="w-full flex items-center gap-3 px-4 py-3 bg-gray-50 hover:bg-gray-100 transition-colors text-left">
|
||||
<span className="text-base">{group.icon}</span>
|
||||
<span className="text-sm font-medium text-gray-900 flex-1">{group.category}</span>
|
||||
{activeCount > 0 && <span className="text-xs bg-purple-100 text-purple-700 px-2 py-0.5 rounded-full">{activeCount} aktiv</span>}
|
||||
<svg className={`w-4 h-4 text-gray-400 transition-transform ${isCollapsed ? '' : 'rotate-180'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{!isCollapsed && (
|
||||
<div className="p-3 space-y-2">
|
||||
{group.systems.map(template => {
|
||||
const isActive = activeIds.has(template.id)
|
||||
const system = aiSystems.find(a => a.id === template.id)
|
||||
const isExpanded = expandedSystem === template.id
|
||||
|
||||
return (
|
||||
<div key={template.id}>
|
||||
<div
|
||||
className={`flex items-center gap-3 p-3 rounded-lg border-2 cursor-pointer transition-all ${isActive ? 'border-purple-500 bg-purple-50' : 'border-gray-100 hover:border-purple-300'}`}
|
||||
onClick={() => { if (!isActive) { toggleTemplateSystem(template) } else { setExpandedSystem(isExpanded ? null : template.id) } }}
|
||||
>
|
||||
<input type="checkbox" checked={isActive} onChange={e => { e.stopPropagation(); toggleTemplateSystem(template) }} className="w-4 h-4 text-purple-600 rounded focus:ring-purple-500 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-gray-900">{template.name}</div>
|
||||
<p className="text-xs text-gray-500">{template.vendor}</p>
|
||||
</div>
|
||||
{isActive && (
|
||||
<svg className={`w-4 h-4 text-gray-400 transition-transform ${isExpanded ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isActive && isExpanded && system && (
|
||||
<div className="ml-4 mt-2 p-4 bg-gray-50 rounded-lg border border-gray-200 space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-2">Einsatzzweck</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{template.typicalPurposes.map(purpose => (
|
||||
<button key={purpose} type="button" onClick={() => togglePurpose(template.id, purpose)}
|
||||
className={`px-3 py-1.5 text-xs rounded-full border transition-all ${(system.purposes || []).includes(purpose) ? 'bg-purple-100 border-purple-300 text-purple-700' : 'bg-white border-gray-200 text-gray-600 hover:border-purple-200'}`}>
|
||||
{purpose}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<input type="text" value={system.notes || ''} onChange={e => updateAISystem(template.id, { notes: e.target.value })} placeholder="Weitere Einsatzzwecke / Anmerkungen..." className="mt-2 w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
|
||||
</div>
|
||||
|
||||
{template.dataWarning && (
|
||||
<div className={`flex items-start gap-2 px-3 py-2 rounded-lg ${template.dataWarning.includes('EU') || template.dataWarning.includes('Deutscher Anbieter') || template.dataWarning.includes('NICHT') ? 'bg-blue-50 border border-blue-200' : 'bg-amber-50 border border-amber-200'}`}>
|
||||
<span className="text-sm mt-0.5">{template.dataWarning.includes('EU') || template.dataWarning.includes('Deutscher Anbieter') ? '\u2139\uFE0F' : '\u26A0\uFE0F'}</span>
|
||||
<span className="text-xs text-gray-800">{template.dataWarning}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="flex items-center gap-2 px-1 cursor-pointer">
|
||||
<input type="checkbox" checked={system.processes_personal_data} onChange={e => updateAISystem(template.id, { processes_personal_data: e.target.checked })} className="w-4 h-4 text-purple-600 rounded focus:ring-purple-500" />
|
||||
<span className="text-sm text-gray-700">Verarbeitet personenbezogene Daten</span>
|
||||
</label>
|
||||
|
||||
<button type="button" onClick={() => removeSystem(template.id)} className="text-xs text-red-500 hover:text-red-700">KI-System entfernen</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{aiSystems.filter(a => a.isCustom).map(system => (
|
||||
<div key={system.id} className="mt-2">
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg border-2 border-purple-500 bg-purple-50 cursor-pointer" onClick={() => setExpandedSystem(expandedSystem === system.id ? null : system.id)}>
|
||||
<span className="w-4 h-4 flex items-center justify-center text-purple-600 flex-shrink-0 text-sm">+</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium text-gray-900">{system.name || 'Neues KI-System'}</span>
|
||||
{system.vendor && <span className="text-xs text-gray-500 ml-2">({system.vendor})</span>}
|
||||
</div>
|
||||
<svg className={`w-4 h-4 text-gray-400 transition-transform ${expandedSystem === system.id ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
{expandedSystem === system.id && (
|
||||
<div className="ml-4 mt-2 p-4 bg-gray-50 rounded-lg border border-gray-200 space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<input type="text" value={system.name} onChange={e => updateAISystem(system.id, { name: e.target.value })} placeholder="Name (z.B. ChatGPT, Copilot)" className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
|
||||
<input type="text" value={system.vendor} onChange={e => updateAISystem(system.id, { vendor: e.target.value })} placeholder="Anbieter (z.B. OpenAI, Microsoft)" className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
|
||||
</div>
|
||||
<input type="text" value={system.purpose} onChange={e => updateAISystem(system.id, { purpose: e.target.value })} placeholder="Einsatzzweck (z.B. Kundensupport, Code-Assistenz)" className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
|
||||
<label className="flex items-center gap-2 px-1 cursor-pointer">
|
||||
<input type="checkbox" checked={system.processes_personal_data} onChange={e => updateAISystem(system.id, { processes_personal_data: e.target.checked })} className="w-4 h-4 text-purple-600 rounded focus:ring-purple-500" />
|
||||
<span className="text-sm text-gray-700">Verarbeitet personenbezogene Daten</span>
|
||||
</label>
|
||||
<button type="button" onClick={() => removeSystem(system.id)} className="text-xs text-red-500 hover:text-red-700">KI-System entfernen</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button type="button" onClick={addCustomSystem} className="w-full mt-3 px-3 py-2 text-sm text-purple-700 bg-purple-50 border-2 border-dashed border-purple-200 rounded-lg hover:bg-purple-100 hover:border-purple-300 transition-colors">
|
||||
+ Eigenes KI-System hinzufuegen
|
||||
</button>
|
||||
|
||||
<div className="mt-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-lg">{'\u2139\uFE0F'}</span>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-blue-900 mb-1">AI Act Risikoeinstufung</h4>
|
||||
<p className="text-xs text-blue-800 mb-3">
|
||||
Die detaillierte Risikoeinstufung Ihrer KI-Systeme nach EU AI Act (verboten / hochriskant / begrenzt / minimal) erfolgt automatisch im AI-Act-Modul.
|
||||
</p>
|
||||
<a href="/sdk/ai-act" className="inline-flex items-center gap-1 text-sm font-medium text-blue-700 hover:text-blue-900">
|
||||
Zum AI-Act-Modul
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
'use client'
|
||||
|
||||
import { CompanyProfile, LegalForm } from '@/lib/sdk/types'
|
||||
import { INDUSTRIES, LEGAL_FORM_LABELS } from './constants'
|
||||
|
||||
export function StepBasicInfo({
|
||||
data,
|
||||
onChange,
|
||||
}: {
|
||||
data: Partial<CompanyProfile>
|
||||
onChange: (updates: Partial<CompanyProfile>) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Firmenname <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={data.companyName || ''}
|
||||
onChange={e => onChange({ companyName: e.target.value })}
|
||||
placeholder="Ihre Firma (ohne Rechtsform)"
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Rechtsform <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={data.legalForm || ''}
|
||||
onChange={e => onChange({ legalForm: e.target.value as LegalForm })}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">Bitte wählen...</option>
|
||||
{Object.entries(LEGAL_FORM_LABELS).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Branche(n)</label>
|
||||
<p className="text-sm text-gray-500 mb-3">Mehrfachauswahl moeglich</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
{INDUSTRIES.map(ind => {
|
||||
const selected = (data.industry || []).includes(ind)
|
||||
return (
|
||||
<button
|
||||
key={ind}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const current = data.industry || []
|
||||
const updated = selected
|
||||
? current.filter(i => i !== ind)
|
||||
: [...current, ind]
|
||||
onChange({ industry: updated })
|
||||
}}
|
||||
className={`p-3 rounded-lg border-2 text-sm text-left transition-all ${
|
||||
selected
|
||||
? 'border-purple-500 bg-purple-50 text-purple-700 font-medium'
|
||||
: 'border-gray-200 hover:border-purple-300 text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{ind}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{(data.industry || []).includes('Sonstige') && (
|
||||
<div className="mt-3">
|
||||
<input
|
||||
type="text"
|
||||
value={data.industryOther || ''}
|
||||
onChange={e => onChange({ industryOther: e.target.value })}
|
||||
placeholder="Ihre Branche eingeben..."
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Gründungsjahr</label>
|
||||
<input
|
||||
type="number"
|
||||
value={data.foundedYear || ''}
|
||||
onChange={e => {
|
||||
const val = parseInt(e.target.value)
|
||||
onChange({ foundedYear: isNaN(val) ? null : val })
|
||||
}}
|
||||
onFocus={e => {
|
||||
if (!data.foundedYear) onChange({ foundedYear: 2000 })
|
||||
}}
|
||||
placeholder="2020"
|
||||
min="1900"
|
||||
max={new Date().getFullYear()}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
CompanyProfile,
|
||||
BusinessModel,
|
||||
OfferingType,
|
||||
BUSINESS_MODEL_LABELS,
|
||||
OFFERING_TYPE_LABELS,
|
||||
} from '@/lib/sdk/types'
|
||||
import { OFFERING_URL_CONFIG } from './constants'
|
||||
|
||||
export function StepBusinessModel({
|
||||
data,
|
||||
onChange,
|
||||
}: {
|
||||
data: Partial<CompanyProfile>
|
||||
onChange: (updates: Partial<CompanyProfile>) => void
|
||||
}) {
|
||||
const toggleOffering = (offering: OfferingType) => {
|
||||
const current = data.offerings || []
|
||||
if (current.includes(offering)) {
|
||||
const urls = { ...(data.offeringUrls || {}) }
|
||||
delete urls[offering]
|
||||
onChange({ offerings: current.filter(o => o !== offering), offeringUrls: urls })
|
||||
} else {
|
||||
onChange({ offerings: [...current, offering] })
|
||||
}
|
||||
}
|
||||
|
||||
const updateOfferingUrl = (offering: string, url: string) => {
|
||||
onChange({ offeringUrls: { ...(data.offeringUrls || {}), [offering]: url } })
|
||||
}
|
||||
|
||||
const selectedWithUrls = (data.offerings || []).filter(o => o in OFFERING_URL_CONFIG)
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-4">
|
||||
Geschäftsmodell <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{Object.entries(BUSINESS_MODEL_LABELS).map(([value, { short }]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => onChange({ businessModel: value as BusinessModel })}
|
||||
className={`p-4 rounded-xl border-2 text-center transition-all ${
|
||||
data.businessModel === value
|
||||
? 'border-purple-500 bg-purple-50 text-purple-700'
|
||||
: 'border-gray-200 hover:border-purple-300'
|
||||
}`}
|
||||
>
|
||||
<div className="font-semibold">{short}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{data.businessModel && (
|
||||
<p className="text-sm text-gray-500 mt-2">
|
||||
{BUSINESS_MODEL_LABELS[data.businessModel].description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-4">
|
||||
Was bieten Sie an? <span className="text-gray-400">(Mehrfachauswahl möglich)</span>
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{Object.entries(OFFERING_TYPE_LABELS).map(([value, { label, description }]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => toggleOffering(value as OfferingType)}
|
||||
className={`p-4 rounded-xl border-2 text-left transition-all ${
|
||||
(data.offerings || []).includes(value as OfferingType)
|
||||
? 'border-purple-500 bg-purple-50'
|
||||
: 'border-gray-200 hover:border-purple-300'
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium text-gray-900">{label}</div>
|
||||
<div className="text-sm text-gray-500">{description}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{(data.offerings || []).includes('webshop') && (data.offerings || []).includes('software_saas') && (
|
||||
<div className="mt-3 flex items-start gap-2 p-3 bg-amber-50 border border-amber-200 rounded-lg">
|
||||
<svg className="w-5 h-5 text-amber-500 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<p className="text-sm text-amber-800">
|
||||
<strong>Hinweis:</strong> Wenn Sie reine Software verkaufen, genuegt <em>SaaS/Cloud</em> — <em>Online-Shop</em> ist nur fuer physische Produkte oder Hardware mit Abo-Modell gedacht.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedWithUrls.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Zugehörige URLs
|
||||
</label>
|
||||
{selectedWithUrls.map(offering => {
|
||||
const config = OFFERING_URL_CONFIG[offering]!
|
||||
return (
|
||||
<div key={offering}>
|
||||
<label className="block text-sm text-gray-600 mb-1">{config.label}</label>
|
||||
<input
|
||||
type="url"
|
||||
value={(data.offeringUrls || {})[offering] || ''}
|
||||
onChange={e => updateOfferingUrl(offering, e.target.value)}
|
||||
placeholder={config.placeholder}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">{config.hint}</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
'use client'
|
||||
|
||||
import { CompanyProfile, CompanySize, COMPANY_SIZE_LABELS } from '@/lib/sdk/types'
|
||||
|
||||
export function StepCompanySize({
|
||||
data,
|
||||
onChange,
|
||||
}: {
|
||||
data: Partial<CompanyProfile>
|
||||
onChange: (updates: Partial<CompanyProfile>) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-4">
|
||||
Unternehmensgröße <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="space-y-3">
|
||||
{Object.entries(COMPANY_SIZE_LABELS).map(([value, label]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => onChange({ companySize: value as CompanySize })}
|
||||
className={`w-full p-4 rounded-xl border-2 text-left transition-all ${
|
||||
data.companySize === value
|
||||
? 'border-purple-500 bg-purple-50'
|
||||
: 'border-gray-200 hover:border-purple-300'
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium text-gray-900">{label}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-3">Jahresumsatz</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{[
|
||||
{ value: '< 2 Mio', label: '< 2 Mio. Euro' },
|
||||
{ value: '2-10 Mio', label: '2-10 Mio. Euro' },
|
||||
{ value: '10-50 Mio', label: '10-50 Mio. Euro' },
|
||||
{ value: '> 50 Mio', label: '> 50 Mio. Euro' },
|
||||
].map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => onChange({ annualRevenue: opt.value })}
|
||||
className={`p-4 rounded-xl border-2 text-left transition-all ${
|
||||
data.annualRevenue === opt.value
|
||||
? 'border-purple-500 bg-purple-50 text-purple-700 font-medium'
|
||||
: 'border-gray-200 hover:border-purple-300 text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{(data.companySize === 'medium' || data.companySize === 'large' || data.companySize === 'enterprise') && (
|
||||
<p className="text-xs text-amber-600 mt-2">
|
||||
Geben Sie den konsolidierten Konzernumsatz an, wenn der Compliance-Check für Mutter- und Tochtergesellschaften gelten soll.
|
||||
Für eine einzelne Einheit eines Konzerns geben Sie nur deren Umsatz an.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
'use client'
|
||||
|
||||
import { CompanyProfile } from '@/lib/sdk/types'
|
||||
|
||||
export function StepDataProtection({
|
||||
data,
|
||||
onChange,
|
||||
}: {
|
||||
data: Partial<CompanyProfile>
|
||||
onChange: (updates: Partial<CompanyProfile>) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-4">
|
||||
Datenschutz-Rolle nach DSGVO
|
||||
</label>
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-start gap-4 p-4 rounded-xl border-2 border-gray-200 hover:border-purple-300 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={data.isDataController ?? true}
|
||||
onChange={e => onChange({ isDataController: e.target.checked })}
|
||||
className="mt-1 w-5 h-5 text-purple-600 rounded focus:ring-purple-500"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">Verantwortlicher (Art. 4 Nr. 7 DSGVO)</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
Wir entscheiden selbst über Zwecke und Mittel der Datenverarbeitung
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="flex items-start gap-4 p-4 rounded-xl border-2 border-gray-200 hover:border-purple-300 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={data.isDataProcessor ?? false}
|
||||
onChange={e => onChange({ isDataProcessor: e.target.checked })}
|
||||
className="mt-1 w-5 h-5 text-purple-600 rounded focus:ring-purple-500"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">Auftragsverarbeiter (Art. 4 Nr. 8 DSGVO)</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
Wir verarbeiten personenbezogene Daten im Auftrag anderer Unternehmen
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Datenschutzbeauftragter (Name)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={data.dpoName || ''}
|
||||
onChange={e => onChange({ dpoName: e.target.value || null })}
|
||||
placeholder="Optional"
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">DSB E-Mail</label>
|
||||
<input
|
||||
type="email"
|
||||
value={data.dpoEmail || ''}
|
||||
onChange={e => onChange({ dpoEmail: e.target.value || null })}
|
||||
placeholder="dsb@firma.de"
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
'use client'
|
||||
|
||||
import { CompanyProfile } from '@/lib/sdk/types'
|
||||
import { CertificationEntry } from './types'
|
||||
import { CERTIFICATIONS } from './constants'
|
||||
|
||||
export function StepLegalFramework({
|
||||
data,
|
||||
onChange,
|
||||
}: {
|
||||
data: Partial<CompanyProfile>
|
||||
onChange: (updates: Record<string, unknown>) => void
|
||||
}) {
|
||||
const contacts = (data as any).technicalContacts || []
|
||||
const existingCerts: CertificationEntry[] = (data as any).existingCertifications || []
|
||||
const targetCerts: string[] = (data as any).targetCertifications || []
|
||||
const targetCertOther: string = (data as any).targetCertificationOther || ''
|
||||
|
||||
const toggleExistingCert = (certId: string) => {
|
||||
const exists = existingCerts.find((c: CertificationEntry) => c.certId === certId)
|
||||
if (exists) {
|
||||
onChange({ existingCertifications: existingCerts.filter((c: CertificationEntry) => c.certId !== certId) })
|
||||
} else {
|
||||
onChange({ existingCertifications: [...existingCerts, { certId }] })
|
||||
}
|
||||
}
|
||||
|
||||
const updateExistingCert = (certId: string, updates: Partial<CertificationEntry>) => {
|
||||
onChange({ existingCertifications: existingCerts.map((c: CertificationEntry) => c.certId === certId ? { ...c, ...updates } : c) })
|
||||
}
|
||||
|
||||
const toggleTargetCert = (certId: string) => {
|
||||
if (targetCerts.includes(certId)) {
|
||||
onChange({ targetCertifications: targetCerts.filter((c: string) => c !== certId) })
|
||||
} else {
|
||||
onChange({ targetCertifications: [...targetCerts, certId] })
|
||||
}
|
||||
}
|
||||
|
||||
const addContact = () => { onChange({ technicalContacts: [...contacts, { name: '', role: '', email: '' }] }) }
|
||||
const removeContact = (i: number) => { onChange({ technicalContacts: contacts.filter((_: { name: string; role: string; email: string }, idx: number) => idx !== i) }) }
|
||||
const updateContact = (i: number, updates: Partial<{ name: string; role: string; email: string }>) => {
|
||||
const updated = [...contacts]
|
||||
updated[i] = { ...updated[i], ...updates }
|
||||
onChange({ technicalContacts: updated })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Bestehende Zertifizierungen */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-1">Bestehende Zertifizierungen</h3>
|
||||
<p className="text-sm text-gray-500 mb-3">Ueber welche Zertifizierungen verfuegt Ihr Unternehmen aktuell? Mehrfachauswahl moeglich.</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
{CERTIFICATIONS.map(cert => {
|
||||
const selected = existingCerts.some((c: CertificationEntry) => c.certId === cert.id)
|
||||
return (
|
||||
<button key={cert.id} type="button" onClick={() => toggleExistingCert(cert.id)}
|
||||
className={`p-3 rounded-lg border-2 text-left transition-all ${selected ? 'border-purple-500 bg-purple-50 text-purple-700' : 'border-gray-200 hover:border-purple-300 text-gray-700'}`}>
|
||||
<div className="font-medium text-sm">{cert.label}</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">{cert.desc}</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{existingCerts.length > 0 && (
|
||||
<div className="mt-4 space-y-3">
|
||||
{existingCerts.map((entry: CertificationEntry) => {
|
||||
const cert = CERTIFICATIONS.find(c => c.id === entry.certId)
|
||||
const label = cert?.label || entry.certId
|
||||
return (
|
||||
<div key={entry.certId} className="p-4 bg-purple-50 border border-purple-200 rounded-lg">
|
||||
<div className="font-medium text-sm text-purple-800 mb-2">
|
||||
{entry.certId === 'other' ? 'Sonstige Zertifizierung' : label}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
{entry.certId === 'other' && (
|
||||
<input type="text" value={entry.customName || ''} onChange={e => updateExistingCert(entry.certId, { customName: e.target.value })} placeholder="Name der Zertifizierung" className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
|
||||
)}
|
||||
<input type="text" value={entry.certifier || ''} onChange={e => updateExistingCert(entry.certId, { certifier: e.target.value })} placeholder="Zertifizierer (z.B. T\u00DCV, DEKRA)" className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
|
||||
<input type="date" value={entry.lastDate || ''} onChange={e => updateExistingCert(entry.certId, { lastDate: e.target.value })} title="Datum der letzten Zertifizierung" className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Angestrebte Zertifizierungen */}
|
||||
<div className="border-t border-gray-200 pt-6">
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-1">Streben Sie eine Zertifizierung an?</h3>
|
||||
<p className="text-sm text-gray-500 mb-3">Welche Zertifizierungen planen Sie? Mehrfachauswahl moeglich.</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
{CERTIFICATIONS.map(cert => {
|
||||
const selected = targetCerts.includes(cert.id)
|
||||
const alreadyHas = existingCerts.some((c: CertificationEntry) => c.certId === cert.id)
|
||||
return (
|
||||
<button key={cert.id} type="button" onClick={() => !alreadyHas && toggleTargetCert(cert.id)} disabled={alreadyHas}
|
||||
className={`p-3 rounded-lg border-2 text-left transition-all ${alreadyHas ? 'border-gray-100 bg-gray-50 text-gray-400 cursor-not-allowed' : selected ? 'border-green-500 bg-green-50 text-green-700' : 'border-gray-200 hover:border-green-300 text-gray-700'}`}>
|
||||
<div className="font-medium text-sm">{cert.label}</div>
|
||||
{alreadyHas && <div className="text-xs mt-0.5">Bereits vorhanden</div>}
|
||||
{!alreadyHas && <div className="text-xs text-gray-500 mt-0.5">{cert.desc}</div>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{targetCerts.includes('other') && (
|
||||
<div className="mt-3">
|
||||
<input type="text" value={targetCertOther} onChange={e => onChange({ targetCertificationOther: e.target.value })} placeholder="Name der angestrebten Zertifizierung" className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Technical Contacts */}
|
||||
<div className="border-t border-gray-200 pt-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-700">Technische Ansprechpartner</h3>
|
||||
<p className="text-xs text-gray-500">CISO, IT-Manager, DSB etc.</p>
|
||||
</div>
|
||||
<button type="button" onClick={addContact} className="px-3 py-1.5 text-sm bg-purple-100 text-purple-700 rounded-lg hover:bg-purple-200">
|
||||
+ Kontakt
|
||||
</button>
|
||||
</div>
|
||||
{contacts.length === 0 && (
|
||||
<div className="text-center py-4 text-gray-400 border-2 border-dashed rounded-lg text-sm">Noch keine Kontakte</div>
|
||||
)}
|
||||
<div className="space-y-3">
|
||||
{contacts.map((c: { name: string; role: string; email: string }, i: number) => (
|
||||
<div key={i} className="flex gap-3 items-center">
|
||||
<input type="text" value={c.name} onChange={e => updateContact(i, { name: e.target.value })} placeholder="Name" className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
|
||||
<input type="text" value={c.role} onChange={e => updateContact(i, { role: e.target.value })} placeholder="Rolle (z.B. CISO)" className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
|
||||
<input type="email" value={c.email} onChange={e => updateContact(i, { email: e.target.value })} placeholder="E-Mail" className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
|
||||
<button type="button" onClick={() => removeContact(i)} className="text-red-400 hover:text-red-600 text-sm">X</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
'use client'
|
||||
|
||||
import { CompanyProfile, TargetMarket, TARGET_MARKET_LABELS } from '@/lib/sdk/types'
|
||||
|
||||
const STATES_BY_COUNTRY: Record<string, { label: string; options: string[] }> = {
|
||||
DE: {
|
||||
label: 'Bundesland',
|
||||
options: [
|
||||
'Baden-W\u00FCrttemberg', 'Bayern', 'Berlin', 'Brandenburg', 'Bremen',
|
||||
'Hamburg', 'Hessen', 'Mecklenburg-Vorpommern', 'Niedersachsen',
|
||||
'Nordrhein-Westfalen', 'Rheinland-Pfalz', 'Saarland', 'Sachsen',
|
||||
'Sachsen-Anhalt', 'Schleswig-Holstein', 'Th\u00FCringen',
|
||||
],
|
||||
},
|
||||
AT: {
|
||||
label: 'Bundesland',
|
||||
options: [
|
||||
'Burgenland', 'K\u00E4rnten', 'Nieder\u00F6sterreich', 'Ober\u00F6sterreich',
|
||||
'Salzburg', 'Steiermark', 'Tirol', 'Vorarlberg', 'Wien',
|
||||
],
|
||||
},
|
||||
CH: {
|
||||
label: 'Kanton',
|
||||
options: [
|
||||
'Aargau', 'Appenzell Ausserrhoden', 'Appenzell Innerrhoden',
|
||||
'Basel-Landschaft', 'Basel-Stadt', 'Bern', 'Freiburg', 'Genf',
|
||||
'Glarus', 'Graub\u00FCnden', 'Jura', 'Luzern', 'Neuenburg', 'Nidwalden',
|
||||
'Obwalden', 'Schaffhausen', 'Schwyz', 'Solothurn', 'St. Gallen',
|
||||
'Tessin', 'Thurgau', 'Uri', 'Waadt', 'Wallis', 'Zug', 'Z\u00FCrich',
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
export function StepLocations({
|
||||
data,
|
||||
onChange,
|
||||
}: {
|
||||
data: Partial<CompanyProfile>
|
||||
onChange: (updates: Partial<CompanyProfile>) => void
|
||||
}) {
|
||||
const toggleMarket = (market: TargetMarket) => {
|
||||
const current = data.targetMarkets || []
|
||||
if (current.includes(market)) {
|
||||
onChange({ targetMarkets: current.filter(m => m !== market) })
|
||||
} else {
|
||||
onChange({ targetMarkets: [...current, market] })
|
||||
}
|
||||
}
|
||||
|
||||
const countryStates = data.headquartersCountry ? STATES_BY_COUNTRY[data.headquartersCountry] : null
|
||||
const stateLabel = countryStates?.label || 'Region / Provinz'
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Country */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Land des Hauptsitzes <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={data.headquartersCountry || ''}
|
||||
onChange={e => onChange({ headquartersCountry: e.target.value, headquartersCountryOther: '' })}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">Bitte wählen...</option>
|
||||
<option value="DE">Deutschland</option>
|
||||
<option value="AT">Österreich</option>
|
||||
<option value="CH">Schweiz</option>
|
||||
<option value="LI">Liechtenstein</option>
|
||||
<option value="LU">Luxemburg</option>
|
||||
<option value="NL">Niederlande</option>
|
||||
<option value="FR">Frankreich</option>
|
||||
<option value="IT">Italien</option>
|
||||
<option value="other">Anderes Land</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{data.headquartersCountry === 'other' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Land (Freitext)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={data.headquartersCountryOther || ''}
|
||||
onChange={e => onChange({ headquartersCountryOther: e.target.value })}
|
||||
placeholder="z.B. Vereinigtes Königreich"
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Street + House Number */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Straße und Hausnummer</label>
|
||||
<input
|
||||
type="text"
|
||||
value={data.headquartersStreet || ''}
|
||||
onChange={e => onChange({ headquartersStreet: e.target.value })}
|
||||
placeholder="Musterstraße 42"
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* PLZ + City */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">PLZ</label>
|
||||
<input
|
||||
type="text"
|
||||
value={data.headquartersZip || ''}
|
||||
onChange={e => onChange({ headquartersZip: e.target.value })}
|
||||
placeholder="10115"
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Stadt</label>
|
||||
<input
|
||||
type="text"
|
||||
value={data.headquartersCity || ''}
|
||||
onChange={e => onChange({ headquartersCity: e.target.value })}
|
||||
placeholder="Berlin"
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* State / Bundesland / Kanton */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{stateLabel}</label>
|
||||
{countryStates ? (
|
||||
<select
|
||||
value={data.headquartersState || ''}
|
||||
onChange={e => onChange({ headquartersState: e.target.value })}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">Bitte wählen...</option>
|
||||
{countryStates.options.map(s => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
value={data.headquartersState || ''}
|
||||
onChange={e => onChange({ headquartersState: e.target.value })}
|
||||
placeholder="Region / Provinz"
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-4">
|
||||
Zielmärkte <span className="text-red-500">*</span>
|
||||
<span className="text-gray-400 font-normal ml-2">Wo verkaufen/operieren Sie?</span>
|
||||
</label>
|
||||
<div className="space-y-3">
|
||||
{Object.entries(TARGET_MARKET_LABELS).map(([value, { label, description }]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => toggleMarket(value as TargetMarket)}
|
||||
className={`w-full p-4 rounded-xl border-2 text-left transition-all ${
|
||||
(data.targetMarkets || []).includes(value as TargetMarket)
|
||||
? 'border-purple-500 bg-purple-50'
|
||||
: 'border-gray-200 hover:border-purple-300'
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium text-gray-900">{label}</div>
|
||||
<div className="text-sm text-gray-500">{description}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
CompanyProfile,
|
||||
MachineBuilderProfile,
|
||||
MachineProductType,
|
||||
AIIntegrationType,
|
||||
HumanOversightLevel,
|
||||
CriticalSector,
|
||||
MACHINE_PRODUCT_TYPE_LABELS,
|
||||
AI_INTEGRATION_TYPE_LABELS,
|
||||
HUMAN_OVERSIGHT_LABELS,
|
||||
CRITICAL_SECTOR_LABELS,
|
||||
} from '@/lib/sdk/types'
|
||||
|
||||
const EMPTY_MACHINE_BUILDER: MachineBuilderProfile = {
|
||||
productTypes: [], productDescription: '', productPride: '',
|
||||
containsSoftware: false, containsFirmware: false, containsAI: false,
|
||||
aiIntegrationType: [], hasSafetyFunction: false, safetyFunctionDescription: '',
|
||||
autonomousBehavior: false, humanOversightLevel: 'full',
|
||||
isNetworked: false, hasRemoteAccess: false, hasOTAUpdates: false, updateMechanism: '',
|
||||
exportMarkets: [], criticalSectorClients: false, criticalSectors: [],
|
||||
oemClients: false, ceMarkingRequired: false, existingCEProcess: false, hasRiskAssessment: false,
|
||||
}
|
||||
|
||||
export function StepMachineBuilder({
|
||||
data,
|
||||
onChange,
|
||||
}: {
|
||||
data: Partial<CompanyProfile>
|
||||
onChange: (updates: Partial<CompanyProfile>) => void
|
||||
}) {
|
||||
const mb = data.machineBuilder || EMPTY_MACHINE_BUILDER
|
||||
|
||||
const updateMB = (updates: Partial<MachineBuilderProfile>) => {
|
||||
onChange({ machineBuilder: { ...mb, ...updates } })
|
||||
}
|
||||
|
||||
const toggleProductType = (type: MachineProductType) => {
|
||||
const current = mb.productTypes || []
|
||||
updateMB({ productTypes: current.includes(type) ? current.filter(t => t !== type) : [...current, type] })
|
||||
}
|
||||
|
||||
const toggleAIType = (type: AIIntegrationType) => {
|
||||
const current = mb.aiIntegrationType || []
|
||||
updateMB({ aiIntegrationType: current.includes(type) ? current.filter(t => t !== type) : [...current, type] })
|
||||
}
|
||||
|
||||
const toggleCriticalSector = (sector: CriticalSector) => {
|
||||
const current = mb.criticalSectors || []
|
||||
updateMB({ criticalSectors: current.includes(sector) ? current.filter(s => s !== sector) : [...current, sector] })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Block 1: Product description */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-1">Erzaehlen Sie uns von Ihrer Anlage</h3>
|
||||
<p className="text-sm text-gray-500 mb-4">Je besser wir Ihr Produkt verstehen, desto praeziser koennen wir die relevanten Vorschriften identifizieren.</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Was baut Ihr Unternehmen? <span className="text-red-500">*</span></label>
|
||||
<textarea value={mb.productDescription} onChange={e => updateMB({ productDescription: e.target.value })} placeholder="z.B. Wir bauen automatisierte Pruefstaende fuer die Qualitaetskontrolle in der Automobilindustrie..." rows={3} className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Was macht Ihre Anlage besonders?</label>
|
||||
<textarea value={mb.productPride} onChange={e => updateMB({ productPride: e.target.value })} placeholder="z.B. Unsere Anlage kann 500 Teile/Stunde mit 99.9% Erkennungsrate pruefen..." rows={2} className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-3">Produkttyp <span className="text-gray-400">(Mehrfachauswahl)</span></label>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
{Object.entries(MACHINE_PRODUCT_TYPE_LABELS).map(([value, label]) => (
|
||||
<button key={value} type="button" onClick={() => toggleProductType(value as MachineProductType)}
|
||||
className={`px-4 py-3 rounded-lg border-2 text-sm font-medium transition-all ${mb.productTypes.includes(value as MachineProductType) ? 'border-purple-500 bg-purple-50 text-purple-700' : 'border-gray-200 hover:border-purple-300 text-gray-700'}`}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Block 2: Software & KI */}
|
||||
<div className="border-t border-gray-200 pt-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Software & KI in Ihrem Produkt</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
{[
|
||||
{ key: 'containsSoftware', label: 'Enthaelt Software', desc: 'Anwendungssoftware in der Maschine' },
|
||||
{ key: 'containsFirmware', label: 'Enthaelt Firmware', desc: 'Embedded Software / Steuerung' },
|
||||
{ key: 'containsAI', label: 'Enthaelt KI/ML', desc: 'Kuenstliche Intelligenz / Machine Learning' },
|
||||
].map(item => (
|
||||
<label key={item.key} className={`flex items-start gap-3 p-4 rounded-xl border-2 cursor-pointer transition-all ${(mb as any)[item.key] ? 'border-purple-500 bg-purple-50' : 'border-gray-200 hover:border-purple-300'}`}>
|
||||
<input type="checkbox" checked={(mb as any)[item.key] ?? false} onChange={e => updateMB({ [item.key]: e.target.checked } as any)} className="mt-1 w-5 h-5 text-purple-600 rounded focus:ring-purple-500" />
|
||||
<div>
|
||||
<div className="font-medium text-gray-900 text-sm">{item.label}</div>
|
||||
<div className="text-xs text-gray-500">{item.desc}</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{mb.containsAI && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-3">Art der KI-Integration</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{Object.entries(AI_INTEGRATION_TYPE_LABELS).map(([value, label]) => (
|
||||
<button key={value} type="button" onClick={() => toggleAIType(value as AIIntegrationType)}
|
||||
className={`px-4 py-2 rounded-lg border text-sm transition-all ${mb.aiIntegrationType.includes(value as AIIntegrationType) ? 'border-purple-500 bg-purple-50 text-purple-700' : 'border-gray-200 hover:border-purple-300 text-gray-700'}`}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<label className={`flex items-start gap-3 p-4 rounded-xl border-2 cursor-pointer transition-all ${mb.hasSafetyFunction ? 'border-red-400 bg-red-50' : 'border-gray-200 hover:border-gray-300'}`}>
|
||||
<input type="checkbox" checked={mb.hasSafetyFunction} onChange={e => updateMB({ hasSafetyFunction: e.target.checked })} className="mt-1 w-5 h-5 text-red-600 rounded focus:ring-red-500" />
|
||||
<div>
|
||||
<div className="font-medium text-gray-900 text-sm">Sicherheitsrelevante Funktion</div>
|
||||
<div className="text-xs text-gray-500">KI/SW hat sicherheitsrelevante Funktion</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className={`flex items-start gap-3 p-4 rounded-xl border-2 cursor-pointer transition-all ${mb.autonomousBehavior ? 'border-amber-400 bg-amber-50' : 'border-gray-200 hover:border-gray-300'}`}>
|
||||
<input type="checkbox" checked={mb.autonomousBehavior} onChange={e => updateMB({ autonomousBehavior: e.target.checked })} className="mt-1 w-5 h-5 text-amber-600 rounded focus:ring-amber-500" />
|
||||
<div>
|
||||
<div className="font-medium text-gray-900 text-sm">Autonomes Verhalten</div>
|
||||
<div className="text-xs text-gray-500">System lernt oder handelt eigenstaendig</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{mb.hasSafetyFunction && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Beschreibung der Sicherheitsfunktion</label>
|
||||
<textarea value={mb.safetyFunctionDescription} onChange={e => updateMB({ safetyFunctionDescription: e.target.value })} placeholder="z.B. KI-Vision ueberwacht den Schutzbereich und stoppt den Roboter bei Personenerkennung..." rows={2} className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Human Oversight Level</label>
|
||||
<select value={mb.humanOversightLevel} onChange={e => updateMB({ humanOversightLevel: e.target.value as HumanOversightLevel })} className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent">
|
||||
{Object.entries(HUMAN_OVERSIGHT_LABELS).map(([value, label]) => (
|
||||
<option key={value} value={value}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Block 3: Konnektivitaet & Updates */}
|
||||
<div className="border-t border-gray-200 pt-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Konnektivitaet & Updates</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-4">
|
||||
{[
|
||||
{ key: 'isNetworked', label: 'Vernetzt', desc: 'Maschine ist mit Netzwerk verbunden' },
|
||||
{ key: 'hasRemoteAccess', label: 'Remote-Zugriff', desc: 'Fernwartung / Remote-Zugang' },
|
||||
{ key: 'hasOTAUpdates', label: 'OTA-Updates', desc: 'Drahtlose Software-/Firmware-Updates' },
|
||||
].map(item => (
|
||||
<label key={item.key} className={`flex items-start gap-3 p-4 rounded-xl border-2 cursor-pointer transition-all ${(mb as any)[item.key] ? 'border-purple-500 bg-purple-50' : 'border-gray-200 hover:border-purple-300'}`}>
|
||||
<input type="checkbox" checked={(mb as any)[item.key] ?? false} onChange={e => updateMB({ [item.key]: e.target.checked } as any)} className="mt-1 w-5 h-5 text-purple-600 rounded focus:ring-purple-500" />
|
||||
<div>
|
||||
<div className="font-medium text-gray-900 text-sm">{item.label}</div>
|
||||
<div className="text-xs text-gray-500">{item.desc}</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{(mb.hasOTAUpdates || mb.hasRemoteAccess) && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Wie werden Updates eingespielt?</label>
|
||||
<input type="text" value={mb.updateMechanism} onChange={e => updateMB({ updateMechanism: e.target.value })} placeholder="z.B. VPN-gesicherter Remote-Zugang mit manueller Freigabe..." className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Block 4: Markt & Kunden */}
|
||||
<div className="border-t border-gray-200 pt-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Markt & Kunden</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<label className={`flex items-start gap-3 p-4 rounded-xl border-2 cursor-pointer transition-all ${mb.criticalSectorClients ? 'border-red-400 bg-red-50' : 'border-gray-200 hover:border-gray-300'}`}>
|
||||
<input type="checkbox" checked={mb.criticalSectorClients} onChange={e => updateMB({ criticalSectorClients: e.target.checked })} className="mt-1 w-5 h-5 text-red-600 rounded focus:ring-red-500" />
|
||||
<div>
|
||||
<div className="font-medium text-gray-900 text-sm">Liefert an KRITIS-Betreiber</div>
|
||||
<div className="text-xs text-gray-500">Kunden in kritischer Infrastruktur</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className={`flex items-start gap-3 p-4 rounded-xl border-2 cursor-pointer transition-all ${mb.oemClients ? 'border-purple-500 bg-purple-50' : 'border-gray-200 hover:border-gray-300'}`}>
|
||||
<input type="checkbox" checked={mb.oemClients} onChange={e => updateMB({ oemClients: e.target.checked })} className="mt-1 w-5 h-5 text-purple-600 rounded focus:ring-purple-500" />
|
||||
<div>
|
||||
<div className="font-medium text-gray-900 text-sm">OEM-Zulieferer</div>
|
||||
<div className="text-xs text-gray-500">Liefern Komponenten an andere Hersteller</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{mb.criticalSectorClients && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-3">Kritische Sektoren Ihrer Kunden</label>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
||||
{Object.entries(CRITICAL_SECTOR_LABELS).map(([value, label]) => (
|
||||
<button key={value} type="button" onClick={() => toggleCriticalSector(value as CriticalSector)}
|
||||
className={`px-3 py-2 rounded-lg border text-sm transition-all ${mb.criticalSectors.includes(value as CriticalSector) ? 'border-red-400 bg-red-50 text-red-700' : 'border-gray-200 hover:border-gray-300 text-gray-700'}`}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<label className={`flex items-start gap-3 p-4 rounded-xl border-2 cursor-pointer transition-all ${mb.ceMarkingRequired ? 'border-blue-500 bg-blue-50' : 'border-gray-200 hover:border-gray-300'}`}>
|
||||
<input type="checkbox" checked={mb.ceMarkingRequired} onChange={e => updateMB({ ceMarkingRequired: e.target.checked })} className="mt-1 w-5 h-5 text-blue-600 rounded focus:ring-blue-500" />
|
||||
<div>
|
||||
<div className="font-medium text-gray-900 text-sm">CE-Kennzeichnung erforderlich</div>
|
||||
<div className="text-xs text-gray-500">Produkt benoetigt CE-Zertifizierung</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className={`flex items-start gap-3 p-4 rounded-xl border-2 cursor-pointer transition-all ${mb.existingCEProcess ? 'border-green-400 bg-green-50' : 'border-gray-200 hover:border-gray-300'}`}>
|
||||
<input type="checkbox" checked={mb.existingCEProcess} onChange={e => updateMB({ existingCEProcess: e.target.checked })} className="mt-1 w-5 h-5 text-green-600 rounded focus:ring-green-500" />
|
||||
<div>
|
||||
<div className="font-medium text-gray-900 text-sm">Bestehender CE-Prozess</div>
|
||||
<div className="text-xs text-gray-500">Bereits ein CE-Verfahren etabliert</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{mb.ceMarkingRequired && (
|
||||
<label className={`flex items-start gap-3 p-4 rounded-xl border-2 cursor-pointer transition-all ${mb.hasRiskAssessment ? 'border-green-400 bg-green-50' : 'border-red-400 bg-red-50'}`}>
|
||||
<input type="checkbox" checked={mb.hasRiskAssessment} onChange={e => updateMB({ hasRiskAssessment: e.target.checked })} className="mt-1 w-5 h-5 text-green-600 rounded focus:ring-green-500" />
|
||||
<div>
|
||||
<div className="font-medium text-gray-900 text-sm">Bestehende Risikobeurteilung</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{mb.hasRiskAssessment ? 'Risikobeurteilung vorhanden' : 'Keine bestehende Risikobeurteilung - IACE hilft Ihnen dabei!'}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { CompanyProfile } from '@/lib/sdk/types'
|
||||
import { ProcessingActivity, ActivityTemplate, ActivityDepartment } from './types'
|
||||
import { ALL_DATA_CATEGORIES, ALL_SPECIAL_CATEGORIES, getRelevantDepartments } from './activity-data'
|
||||
|
||||
function CategoryCheckbox({
|
||||
cat,
|
||||
activity,
|
||||
variant,
|
||||
template,
|
||||
expandedInfoCat,
|
||||
onToggleCategory,
|
||||
onToggleInfo,
|
||||
}: {
|
||||
cat: { id: string; label: string; desc: string; info: string }
|
||||
activity: ProcessingActivity
|
||||
variant: 'normal' | 'extra' | 'art9' | 'art9-extra'
|
||||
template?: ActivityTemplate | null
|
||||
expandedInfoCat: string | null
|
||||
onToggleCategory: (activityId: string, categoryId: string) => void
|
||||
onToggleInfo: (key: string | null) => void
|
||||
}) {
|
||||
const infoText = template?.categoryInfo?.[cat.id] || cat.info
|
||||
const isInfoExpanded = expandedInfoCat === `${activity.id}-${cat.id}`
|
||||
const colorClasses = variant.startsWith('art9')
|
||||
? { check: 'text-red-600 focus:ring-red-500', hover: 'hover:bg-red-100', text: variant === 'art9-extra' ? 'text-gray-500' : 'text-gray-700' }
|
||||
: { check: 'text-purple-600 focus:ring-purple-500', hover: 'hover:bg-gray-100', text: variant === 'extra' ? 'text-gray-500' : 'text-gray-700' }
|
||||
|
||||
const aufbewahrungIdx = infoText.indexOf('Aufbewahrung:')
|
||||
const loeschfristIdx = infoText.indexOf('L\u00F6schfrist')
|
||||
const speicherdauerIdx = infoText.indexOf('Speicherdauer:')
|
||||
const retentionIdx = [aufbewahrungIdx, loeschfristIdx, speicherdauerIdx].filter(i => i >= 0).sort((a, b) => a - b)[0] ?? -1
|
||||
const hasRetention = retentionIdx >= 0
|
||||
const mainText = hasRetention ? infoText.slice(0, retentionIdx).replace(/\.\s*$/, '') : infoText
|
||||
const retentionText = hasRetention ? infoText.slice(retentionIdx) : ''
|
||||
|
||||
return (
|
||||
<div key={cat.id}>
|
||||
<label className={`flex items-center gap-2 text-xs p-1.5 rounded ${colorClasses.hover} cursor-pointer`}>
|
||||
<input type="checkbox" checked={activity.data_categories.includes(cat.id)} onChange={() => onToggleCategory(activity.id, cat.id)} className={`w-3.5 h-3.5 ${colorClasses.check} rounded`} />
|
||||
<span className={colorClasses.text}>{cat.label}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={e => { e.preventDefault(); e.stopPropagation(); onToggleInfo(isInfoExpanded ? null : `${activity.id}-${cat.id}`) }}
|
||||
className="ml-auto w-4 h-4 flex items-center justify-center rounded-full bg-gray-200 hover:bg-gray-300 text-gray-500 text-[10px] font-bold flex-shrink-0"
|
||||
title={infoText}
|
||||
>
|
||||
i
|
||||
</button>
|
||||
</label>
|
||||
{isInfoExpanded && (
|
||||
<div className="ml-7 mt-1 mb-1 px-2 py-1.5 bg-blue-50 border border-blue-100 rounded text-[11px] text-blue-800">
|
||||
{hasRetention ? (
|
||||
<>
|
||||
<span>{mainText}</span>
|
||||
<span className="block mt-1 px-1.5 py-0.5 bg-amber-50 border border-amber-200 rounded text-amber-800">
|
||||
<span className="mr-1">🕓</span>{retentionText}
|
||||
</span>
|
||||
</>
|
||||
) : infoText}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ActivityDetail({
|
||||
activity,
|
||||
template,
|
||||
showExtraCategories,
|
||||
expandedInfoCat,
|
||||
onToggleCategory,
|
||||
onToggleInfo,
|
||||
onToggleExtraCategories,
|
||||
onUpdateActivity,
|
||||
onRemoveActivity,
|
||||
}: {
|
||||
activity: ProcessingActivity
|
||||
template: ActivityTemplate | null
|
||||
showExtraCategories: Set<string>
|
||||
expandedInfoCat: string | null
|
||||
onToggleCategory: (activityId: string, categoryId: string) => void
|
||||
onToggleInfo: (key: string | null) => void
|
||||
onToggleExtraCategories: (activityId: string) => void
|
||||
onUpdateActivity: (id: string, updates: Partial<ProcessingActivity>) => void
|
||||
onRemoveActivity: (id: string) => void
|
||||
}) {
|
||||
const primaryIds = new Set(template?.primary_categories || [])
|
||||
const art9Ids = new Set(template?.art9_relevant || [])
|
||||
const primaryCats = ALL_DATA_CATEGORIES.filter(c => primaryIds.has(c.id))
|
||||
const extraCats = ALL_DATA_CATEGORIES.filter(c => !primaryIds.has(c.id))
|
||||
const relevantArt9 = ALL_SPECIAL_CATEGORIES.filter(c => art9Ids.has(c.id))
|
||||
const otherArt9 = ALL_SPECIAL_CATEGORIES.filter(c => !art9Ids.has(c.id))
|
||||
const showingExtra = showExtraCategories.has(activity.id)
|
||||
const isCustom = !template || activity.custom
|
||||
|
||||
return (
|
||||
<div className="ml-4 mt-2 p-4 bg-gray-50 rounded-lg border border-gray-200 space-y-4">
|
||||
{template?.legalHint && (
|
||||
<div className="flex items-start gap-2 px-3 py-2 bg-amber-50 border border-amber-200 rounded-lg">
|
||||
<span className="text-amber-600 text-sm mt-0.5">⚠</span>
|
||||
<span className="text-xs text-amber-800">{template.legalHint}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isCustom && (
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
<input type="text" value={activity.name} onChange={e => onUpdateActivity(activity.id, { name: e.target.value })} placeholder="Name der Verarbeitungst\u00E4tigkeit" className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
|
||||
<input type="text" value={activity.purpose} onChange={e => onUpdateActivity(activity.id, { purpose: e.target.value })} placeholder="Zweck der Verarbeitung" className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{template?.hasServiceProvider && (
|
||||
<div className="bg-blue-50 rounded-lg p-3 border border-blue-100 space-y-2">
|
||||
<label className="flex items-center gap-2 text-xs cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={activity.usesServiceProvider || false}
|
||||
onChange={e => onUpdateActivity(activity.id, {
|
||||
usesServiceProvider: e.target.checked,
|
||||
...(!e.target.checked ? { serviceProviderName: '' } : {})
|
||||
})}
|
||||
className="w-3.5 h-3.5 text-blue-600 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-blue-800 font-medium">Externer Dienstleister wird eingesetzt</span>
|
||||
</label>
|
||||
{activity.usesServiceProvider && (
|
||||
<div className="ml-6">
|
||||
<input
|
||||
type="text"
|
||||
value={activity.serviceProviderName || ''}
|
||||
onChange={e => onUpdateActivity(activity.id, { serviceProviderName: e.target.value })}
|
||||
placeholder="Name des Dienstleisters (optional)"
|
||||
className="w-full px-3 py-1.5 border border-blue-200 rounded text-xs focus:ring-2 focus:ring-blue-400 focus:border-transparent bg-white"
|
||||
/>
|
||||
<p className="text-[10px] text-blue-600 mt-1">Wird als Auftragsverarbeiter (AVV) im VVT erfasst.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-2">Betroffene Datenkategorien</label>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{(isCustom ? ALL_DATA_CATEGORIES : primaryCats).map(cat =>
|
||||
<CategoryCheckbox key={cat.id} cat={cat} activity={activity} variant="normal" template={template} expandedInfoCat={expandedInfoCat} onToggleCategory={onToggleCategory} onToggleInfo={onToggleInfo} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isCustom && extraCats.length > 0 && (
|
||||
<div>
|
||||
<button type="button" onClick={() => onToggleExtraCategories(activity.id)} className="text-xs text-purple-600 hover:text-purple-800">
|
||||
{showingExtra ? '\u25BE Weitere Kategorien ausblenden' : `\u25B8 Weitere ${extraCats.length} Kategorien anzeigen`}
|
||||
</button>
|
||||
{showingExtra && (
|
||||
<div className="grid grid-cols-2 gap-1.5 mt-2">
|
||||
{extraCats.map(cat => <CategoryCheckbox key={cat.id} cat={cat} activity={activity} variant="extra" template={template} expandedInfoCat={expandedInfoCat} onToggleCategory={onToggleCategory} onToggleInfo={onToggleInfo} />)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(isCustom ? ALL_SPECIAL_CATEGORIES.length > 0 : relevantArt9.length > 0) && (
|
||||
<div className="bg-red-50 rounded-lg p-3 border border-red-100">
|
||||
<label className="block text-xs font-medium text-red-700 mb-2">
|
||||
Besondere Kategorien (Art. 9 DSGVO)
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{(isCustom ? ALL_SPECIAL_CATEGORIES : relevantArt9).map(cat =>
|
||||
<CategoryCheckbox key={cat.id} cat={cat} activity={activity} variant="art9" template={template} expandedInfoCat={expandedInfoCat} onToggleCategory={onToggleCategory} onToggleInfo={onToggleInfo} />
|
||||
)}
|
||||
</div>
|
||||
{!isCustom && otherArt9.length > 0 && showingExtra && (
|
||||
<div className="grid grid-cols-2 gap-1.5 mt-2 pt-2 border-t border-red-100">
|
||||
{otherArt9.map(cat => <CategoryCheckbox key={cat.id} cat={cat} activity={activity} variant="art9-extra" template={template} expandedInfoCat={expandedInfoCat} onToggleCategory={onToggleCategory} onToggleInfo={onToggleInfo} />)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button type="button" onClick={() => onRemoveActivity(activity.id)} className="text-xs text-red-500 hover:text-red-700">
|
||||
Verarbeitungstätigkeit entfernen
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function StepProcessing({
|
||||
data,
|
||||
onChange,
|
||||
}: {
|
||||
data: Partial<CompanyProfile> & { processingSystems?: ProcessingActivity[] }
|
||||
onChange: (updates: Record<string, unknown>) => void
|
||||
}) {
|
||||
const activities: ProcessingActivity[] = (data as any).processingSystems || []
|
||||
const industry = data.industry || []
|
||||
const [expandedActivity, setExpandedActivity] = useState<string | null>(null)
|
||||
const [collapsedDepts, setCollapsedDepts] = useState<Set<string>>(new Set())
|
||||
const [showExtraCats, setShowExtraCats] = useState<Set<string>>(new Set())
|
||||
const [expandedInfoCat, setExpandedInfoCat] = useState<string | null>(null)
|
||||
|
||||
const departments = getRelevantDepartments(industry, data.businessModel, data.companySize)
|
||||
const activeIds = new Set(activities.map(a => a.id))
|
||||
|
||||
const toggleActivity = (template: ActivityTemplate, deptId: string) => {
|
||||
if (activeIds.has(template.id)) {
|
||||
onChange({ processingSystems: activities.filter(a => a.id !== template.id) })
|
||||
} else {
|
||||
onChange({
|
||||
processingSystems: [...activities, {
|
||||
id: template.id, name: template.name, purpose: template.purpose,
|
||||
data_categories: [...template.primary_categories],
|
||||
legal_basis: template.default_legal_basis, department: deptId,
|
||||
}],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const updateActivity = (id: string, updates: Partial<ProcessingActivity>) => {
|
||||
onChange({ processingSystems: activities.map(a => a.id === id ? { ...a, ...updates } : a) })
|
||||
}
|
||||
|
||||
const toggleDataCategory = (activityId: string, categoryId: string) => {
|
||||
const activity = activities.find(a => a.id === activityId)
|
||||
if (!activity) return
|
||||
const cats = activity.data_categories.includes(categoryId)
|
||||
? activity.data_categories.filter(c => c !== categoryId)
|
||||
: [...activity.data_categories, categoryId]
|
||||
updateActivity(activityId, { data_categories: cats })
|
||||
}
|
||||
|
||||
const toggleDeptCollapse = (deptId: string) => {
|
||||
setCollapsedDepts(prev => { const next = new Set(prev); if (next.has(deptId)) next.delete(deptId); else next.add(deptId); return next })
|
||||
}
|
||||
|
||||
const toggleExtraCategories = (activityId: string) => {
|
||||
setShowExtraCats(prev => { const next = new Set(prev); if (next.has(activityId)) next.delete(activityId); else next.add(activityId); return next })
|
||||
}
|
||||
|
||||
const addCustomActivity = () => {
|
||||
const id = `custom_${Date.now()}`
|
||||
onChange({ processingSystems: [...activities, { id, name: '', purpose: '', data_categories: [], legal_basis: 'contract', custom: true }] })
|
||||
setExpandedActivity(id)
|
||||
}
|
||||
|
||||
const removeActivity = (id: string) => {
|
||||
onChange({ processingSystems: activities.filter(a => a.id !== id) })
|
||||
if (expandedActivity === id) setExpandedActivity(null)
|
||||
}
|
||||
|
||||
const deptActivityCount = (dept: ActivityDepartment) => dept.activities.filter(a => activeIds.has(a.id)).length
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-1">Verarbeitungstätigkeiten</h3>
|
||||
<p className="text-xs text-gray-500 mb-4">
|
||||
Wählen Sie pro Abteilung aus, welche Verarbeitungen stattfinden. Diese bilden die Grundlage für Ihr Verarbeitungsverzeichnis (VVT) nach Art. 30 DSGVO.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{departments.map(dept => {
|
||||
const isCollapsed = collapsedDepts.has(dept.id)
|
||||
const activeCount = deptActivityCount(dept)
|
||||
|
||||
return (
|
||||
<div key={dept.id} className="border border-gray-200 rounded-lg overflow-hidden">
|
||||
<button type="button" onClick={() => toggleDeptCollapse(dept.id)} className="w-full flex items-center gap-3 px-4 py-3 bg-gray-50 hover:bg-gray-100 transition-colors text-left">
|
||||
<span className="text-base">{dept.icon}</span>
|
||||
<span className="text-sm font-medium text-gray-900 flex-1">{dept.name}</span>
|
||||
{activeCount > 0 && <span className="text-xs bg-purple-100 text-purple-700 px-2 py-0.5 rounded-full">{activeCount} aktiv</span>}
|
||||
<svg className={`w-4 h-4 text-gray-400 transition-transform ${isCollapsed ? '' : 'rotate-180'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{!isCollapsed && (
|
||||
<div className="p-3 space-y-2">
|
||||
{dept.activities.map(template => {
|
||||
const isActive = activeIds.has(template.id)
|
||||
const activity = activities.find(a => a.id === template.id)
|
||||
const isExpanded = expandedActivity === template.id
|
||||
|
||||
return (
|
||||
<div key={template.id}>
|
||||
<div
|
||||
className={`flex items-center gap-3 p-3 rounded-lg border-2 cursor-pointer transition-all ${isActive ? 'border-purple-500 bg-purple-50' : 'border-gray-100 hover:border-purple-300'}`}
|
||||
onClick={() => { if (!isActive) { toggleActivity(template, dept.id); setExpandedActivity(template.id) } else { setExpandedActivity(isExpanded ? null : template.id) } }}
|
||||
>
|
||||
<input type="checkbox" checked={isActive} onChange={e => { e.stopPropagation(); toggleActivity(template, dept.id) }} className="w-4 h-4 text-purple-600 rounded focus:ring-purple-500 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-900">{template.name}</span>
|
||||
{template.legalHint && <span className="text-[10px] bg-amber-100 text-amber-700 px-1.5 py-0.5 rounded font-medium whitespace-nowrap">Pflicht</span>}
|
||||
{template.hasServiceProvider && <span className="text-[10px] bg-blue-100 text-blue-700 px-1.5 py-0.5 rounded font-medium whitespace-nowrap">AVV-relevant</span>}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 truncate">{template.purpose}</p>
|
||||
</div>
|
||||
{isActive && <span className="text-xs text-purple-600 flex-shrink-0">{activity?.data_categories.length || 0} Kat.</span>}
|
||||
{isActive && (
|
||||
<svg className={`w-4 h-4 text-gray-400 transition-transform ${isExpanded ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
{isActive && isExpanded && activity && (
|
||||
<ActivityDetail activity={activity} template={template} showExtraCategories={showExtraCats} expandedInfoCat={expandedInfoCat} onToggleCategory={toggleDataCategory} onToggleInfo={setExpandedInfoCat} onToggleExtraCategories={toggleExtraCategories} onUpdateActivity={updateActivity} onRemoveActivity={removeActivity} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{activities.filter(a => a.custom).map(activity => (
|
||||
<div key={activity.id} className="mt-2">
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg border-2 border-purple-500 bg-purple-50 cursor-pointer" onClick={() => setExpandedActivity(expandedActivity === activity.id ? null : activity.id)}>
|
||||
<span className="w-4 h-4 flex items-center justify-center text-purple-600 flex-shrink-0 text-sm">+</span>
|
||||
<div className="flex-1 min-w-0"><span className="text-sm font-medium text-gray-900">{activity.name || 'Neue Verarbeitungst\u00E4tigkeit'}</span></div>
|
||||
<svg className={`w-4 h-4 text-gray-400 transition-transform ${expandedActivity === activity.id ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
{expandedActivity === activity.id && (
|
||||
<ActivityDetail activity={activity} template={null} showExtraCategories={showExtraCats} expandedInfoCat={expandedInfoCat} onToggleCategory={toggleDataCategory} onToggleInfo={setExpandedInfoCat} onToggleExtraCategories={toggleExtraCategories} onUpdateActivity={updateActivity} onRemoveActivity={removeActivity} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button type="button" onClick={addCustomActivity} className="w-full mt-3 px-3 py-2 text-sm text-purple-700 bg-purple-50 border-2 border-dashed border-purple-200 rounded-lg hover:bg-purple-100 hover:border-purple-300 transition-colors">
|
||||
+ Eigene Verarbeitungstätigkeit hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import { ActivityDepartment } from './types'
|
||||
|
||||
// DSGVO-Standard Datenkategorien
|
||||
export const ALL_DATA_CATEGORIES = [
|
||||
{ id: 'stammdaten', label: 'Stammdaten', desc: 'Name, Geburtsdatum, Geschlecht', info: 'Vor- und Nachname, Geburtsdatum, Geschlecht, Anrede, Titel, Familienstand, Staatsangehörigkeit, Personalnummer, Kundennummer' },
|
||||
{ id: 'kontaktdaten', label: 'Kontaktdaten', desc: 'E-Mail, Telefon, Adresse', info: 'E-Mail-Adresse, Telefonnummer, Mobilnummer, Postanschrift, Faxnummer, Messenger-IDs der betroffenen Personen' },
|
||||
{ id: 'vertragsdaten', label: 'Vertragsdaten', desc: 'Vertragsnummer, Laufzeit, Konditionen', info: 'Vertragsnummer, Vertragsbeginn/-ende, Laufzeit, Konditionen, Kündigungsfristen, Vertragsgegenstand, Bestellhistorie' },
|
||||
{ id: 'zahlungsdaten', label: 'Zahlungs-/Bankdaten', desc: 'IBAN, Kreditkarte, Rechnungen', info: 'IBAN, BIC, Kontoinhaber, Kreditkartennummer, Rechnungsbeträge, Zahlungshistorie, Steuer-ID, USt-IdNr.' },
|
||||
{ id: 'beschaeftigtendaten', label: 'Beschäftigtendaten', desc: 'Gehalt, Arbeitszeiten, Urlaub', info: 'Gehalt/Lohn, Steuerklasse, SV-Nummer, Krankenkasse (z.B. AOK, TK), Arbeitszeiten, Urlaubstage, Abwesenheiten, Beurteilungen, Eintrittsdatum. Aufbewahrung: i.d.R. 3 Jahre nach Austritt (§ 195 BGB), Lohndaten 8 Jahre (§ 147 AO)' },
|
||||
{ id: 'kommunikation', label: 'Kommunikationsdaten', desc: 'E-Mail-Inhalte, Chat-Verläufe', info: 'E-Mail-Inhalte und -Metadaten, Chat-Nachrichten, Gesprächsprotokolle, Support-Tickets, Briefkorrespondenz' },
|
||||
{ id: 'nutzungsdaten', label: 'Nutzungs-/Logdaten', desc: 'IP-Adressen, Login-Zeiten, Klicks', info: 'IP-Adressen, Login-Zeitpunkte, Seitenaufrufe, Klickverhalten, Geräteinformationen, Browser-Typ, Session-Dauer' },
|
||||
{ id: 'standortdaten', label: 'Standortdaten', desc: 'GPS, Check-in, Lieferadressen', info: 'GPS-Koordinaten, Check-in/Check-out-Zeiten, Lieferadressen, Reiserouten, WLAN-Standortbestimmung' },
|
||||
{ id: 'bilddaten', label: 'Bild-/Videodaten', desc: 'Fotos, Videoaufnahmen, Profilbilder', info: 'Profilfotos, Ausweiskopien, Videoaufnahmen (Überwachung), Bewerbungsfotos, Schulungsvideos' },
|
||||
{ id: 'bewerberdaten', label: 'Bewerberdaten', desc: 'Lebenslauf, Zeugnisse, Anschreiben', info: 'Lebenslauf, Anschreiben, Zeugnisse, Referenzen, Gehaltsvorstellungen, Verfügbarkeit, Bewerbungsquelle. Löschfrist bei Absage: max. 6 Monate (AGG §§ 15, 21)' },
|
||||
{ id: 'qualifikationsdaten', label: 'Qualifikations-/Schulungsdaten', desc: 'Fortbildungen, Zertifikate, Abschlüsse', info: 'Besuchte Seminare und Schulungen, Zertifikate, Abschlüsse, Qualifikationsnachweise, Schulungsdaten und -ergebnisse, Weiterbildungshistorie' },
|
||||
] as const
|
||||
|
||||
export const ALL_SPECIAL_CATEGORIES = [
|
||||
{ id: 'gesundheit', label: 'Gesundheitsdaten', desc: 'Krankheitstage, Atteste, Diagnosen', info: 'Krankheitstage, AU-Bescheinigungen, Diagnosen, Behinderungsgrad (GdB), BEM-Daten, arbeitsmedizinische Untersuchungen, Impfstatus, Allergien. Auch AU ohne Diagnose = Gesundheitsdatum (LDI NRW). Schwangerschaft, Allergien, Online-Arzneimittelbestellung (EuGH C-21/23). NICHT: Krankenkassenname (z.B. AOK, TK) — das sind normale Beschäftigtendaten.' },
|
||||
{ id: 'biometrie', label: 'Biometrische Daten', desc: 'Fingerabdruck, Gesichtserkennung', info: 'Fingerabdruck, Gesichtserkennung, Iris-Scan, Stimmerkennung, Handvenenscan. Nur wenn zur eindeutigen Identifizierung verwendet (ErwGr. 51). Einfaches Passfoto = kein biometrisches Datum.' },
|
||||
{ id: 'religion', label: 'Religion', desc: 'Konfession, Kirchensteuer', info: 'Konfession/Religionszugehörigkeit (relevant für Kirchensteuer auf Lohnabrechnung). Auch indirekt: Kantinenbestellung halal/koscher (EuGH C-184/20 weite Auslegung). Praktisch jedes Unternehmen mit Beschäftigten verarbeitet diese Daten über die Gehaltsabrechnung.' },
|
||||
{ id: 'gewerkschaft', label: 'Gewerkschaftszugehörigkeit', desc: 'Mitgliedschaft', info: 'Gewerkschaftsmitgliedschaft, Betriebsratszugehörigkeit, Tarifzugehörigkeit' },
|
||||
{ id: 'genetik', label: 'Genetische Daten', desc: 'DNA, Erbkrankheiten', info: 'DNA-Analysen, genetische Prädispositionen, Erbkrankheitsrisiken (nur in Spezialfällen relevant)' },
|
||||
] as const
|
||||
|
||||
// ── Universelle Abteilungen (immer sichtbar) ──
|
||||
|
||||
const UNIVERSAL_DEPARTMENTS: ActivityDepartment[] = [
|
||||
{
|
||||
id: 'personal', name: 'Personal / HR', icon: '\uD83D\uDC65',
|
||||
activities: [
|
||||
{ id: 'personalverwaltung', name: 'Personalverwaltung', purpose: 'Verwaltung von Beschäftigtendaten für das Arbeitsverhältnis', primary_categories: ['stammdaten', 'kontaktdaten', 'beschaeftigtendaten', 'zahlungsdaten'], art9_relevant: ['gesundheit', 'religion', 'gewerkschaft'], default_legal_basis: 'contract', categoryInfo: { stammdaten: 'Vor-/Nachname, Geburtsdatum, Geschlecht, Familienstand, Staatsangehörigkeit, Personalnummer', kontaktdaten: 'Privat- und Dienstadresse, Telefonnummern, dienstliche E-Mail, Notfallkontakt', beschaeftigtendaten: 'Steuerklasse, SV-Nummer, Krankenkasse (z.B. AOK, TK \u2014 kein Gesundheitsdatum!), Eintrittsdatum, Arbeitszeit, Urlaubstage. Aufbewahrung: 3 Jahre nach Austritt (\u00A7 195 BGB)', zahlungsdaten: 'IBAN f\u00FCr Gehaltsauszahlung, Verm\u00F6genswirksame Leistungen, Pf\u00E4ndungsdaten' } },
|
||||
{ id: 'lohnbuchhaltung', name: 'Lohn- und Gehaltsabrechnung', purpose: 'Berechnung und Auszahlung von L\u00F6hnen und Geh\u00E4ltern', primary_categories: ['beschaeftigtendaten', 'zahlungsdaten', 'stammdaten'], art9_relevant: [], default_legal_basis: 'legal', hasServiceProvider: true, categoryInfo: { beschaeftigtendaten: 'Gehalt, Zulagen, Pr\u00E4mien, Steuerklasse, SV-Nummer, Krankenkasse, Kirchensteuermerkmal. Aufbewahrung: Lohnabrechnungen 8 Jahre (\u00A7 147 AO), Lohnsteuer 6 Jahre (\u00A7 41 EStG). Hinweis: Gesundheits- und Religionsdaten werden bereits unter Personalverwaltung als Art. 9-Kategorien erfasst.', zahlungsdaten: 'IBAN, Bankverbindung, Gehaltsabrechnungen, Pf\u00E4ndungsbetr\u00E4ge. Aufbewahrung: 8 Jahre (\u00A7 147 AO)' } },
|
||||
{ id: 'bewerbermanagement', name: 'Bewerbermanagement', purpose: 'Entgegennahme, Pr\u00FCfung und Bearbeitung von Bewerbungen', primary_categories: ['bewerberdaten', 'stammdaten', 'kontaktdaten', 'kommunikation', 'qualifikationsdaten'], art9_relevant: ['gesundheit', 'religion'], default_legal_basis: 'consent', categoryInfo: { bewerberdaten: 'Lebenslauf, Anschreiben, Zeugnisse, Referenzen, Gehaltsvorstellungen. L\u00F6schfrist bei Absage: max. 6 Monate (AGG \u00A7\u00A7 15, 21)', kontaktdaten: 'Privatadresse, E-Mail, Telefonnummer des Bewerbers', kommunikation: 'Bewerbungskorrespondenz, Einladungen, Absageschreiben' } },
|
||||
{ id: 'arbeitszeiterfassung', name: 'Arbeitszeiterfassung', purpose: 'Erfassung und Dokumentation der Arbeitszeiten', primary_categories: ['beschaeftigtendaten'], art9_relevant: [], default_legal_basis: 'legal', legalHint: 'Gesetzlich vorgeschrieben (\u00A7 3 ArbZG). Fehlende Arbeitszeiterfassung ist ein Compliance-Risiko.', categoryInfo: { beschaeftigtendaten: 'Beginn/Ende der Arbeitszeit, Pausen, \u00DCberstunden, Ruhezeiten. Aufbewahrung: mind. 2 Jahre (\u00A7 16 Abs. 2 ArbZG). Nicht f\u00FCr Leistungskontrolle verwenden!' } },
|
||||
{ id: 'weiterbildung', name: 'Fort- und Weiterbildung', purpose: 'Verwaltung von Schulungen und Weiterbildungsma\u00DFnahmen', primary_categories: ['qualifikationsdaten', 'beschaeftigtendaten', 'stammdaten'], art9_relevant: [], default_legal_basis: 'contract' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'finanzen', name: 'Finanzen / Buchhaltung', icon: '\uD83D\uDCB0',
|
||||
activities: [
|
||||
{ id: 'finanzbuchhaltung', name: 'Finanzbuchhaltung', purpose: 'Buchf\u00FChrung, Rechnungsstellung, steuerliche Dokumentation', primary_categories: ['stammdaten', 'zahlungsdaten', 'vertragsdaten', 'kontaktdaten'], art9_relevant: [], default_legal_basis: 'legal', categoryInfo: { zahlungsdaten: 'Rechnungsbetr\u00E4ge, IBAN, Buchungsbelege, USt-IdNr. Aufbewahrung: 8 Jahre (\u00A7 147 AO)', vertragsdaten: 'Vertragsnummer, Konditionen, Bestellhistorie. Aufbewahrung: Handelskorrespondenz 6 Jahre (\u00A7 257 HGB)', kontaktdaten: 'Rechnungsadresse, Ansprechpartner in der Debitorenbuchhaltung' } },
|
||||
{ id: 'zahlungsverkehr', name: 'Zahlungsverkehr', purpose: 'Abwicklung von ein- und ausgehenden Zahlungen', primary_categories: ['zahlungsdaten', 'stammdaten', 'kontaktdaten', 'vertragsdaten'], art9_relevant: [], default_legal_basis: 'contract' },
|
||||
{ id: 'mahnwesen', name: 'Mahnwesen / Inkasso', purpose: '\u00DCberwachung offener Forderungen und Mahnverfahren', primary_categories: ['stammdaten', 'kontaktdaten', 'zahlungsdaten', 'vertragsdaten'], art9_relevant: [], default_legal_basis: 'interest' },
|
||||
{ id: 'reisekostenabrechnung', name: 'Reisekostenabrechnung', purpose: 'Abrechnung und Erstattung von Dienstreisekosten', primary_categories: ['beschaeftigtendaten', 'zahlungsdaten', 'standortdaten'], art9_relevant: [], default_legal_basis: 'contract' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'vertrieb', name: 'Vertrieb / Sales', icon: '\uD83D\uDCC8',
|
||||
activities: [
|
||||
{ id: 'crm', name: 'CRM / Kundenverwaltung', purpose: 'Verwaltung von Kundenbeziehungen, Kontakthistorie, Verkaufschancen', primary_categories: ['stammdaten', 'kontaktdaten', 'kommunikation', 'vertragsdaten'], art9_relevant: [], default_legal_basis: 'contract', categoryInfo: { stammdaten: 'Firmenname, Ansprechpartner-Name, Titel, Position, Kundennummer', kontaktdaten: 'Gesch\u00E4ftliche E-Mail, Telefon, B\u00FCroadresse des Ansprechpartners. B2B-Kontaktdaten sind personenbezogene Daten \u2014 Art. 13 DSGVO Informationspflicht gilt!', kommunikation: 'E-Mail-Korrespondenz, Gespr\u00E4chsnotizen, Support-Tickets, Meeting-Protokolle' } },
|
||||
{ id: 'angebotserstellung', name: 'Angebotserstellung', purpose: 'Erstellung und Nachverfolgung von Angeboten', primary_categories: ['stammdaten', 'kontaktdaten', 'vertragsdaten'], art9_relevant: [], default_legal_basis: 'contract' },
|
||||
{ id: 'vertragsmanagement', name: 'Vertragsmanagement', purpose: 'Verwaltung, Archivierung und Nachverfolgung von Vertr\u00E4gen', primary_categories: ['vertragsdaten', 'stammdaten', 'kontaktdaten'], art9_relevant: [], default_legal_basis: 'contract' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'marketing', name: 'Marketing', icon: '\uD83D\uDCE3',
|
||||
activities: [
|
||||
{ id: 'newsletter', name: 'Newsletter / E-Mail-Marketing', purpose: 'Versand von Newslettern und E-Mail-Marketing an Abonnenten', primary_categories: ['kontaktdaten', 'nutzungsdaten', 'stammdaten'], art9_relevant: [], default_legal_basis: 'consent' },
|
||||
{ id: 'website_tracking', name: 'Website-Tracking / Analytics', purpose: 'Analyse des Nutzerverhaltens auf der Website mittels Tracking-Tools', primary_categories: ['nutzungsdaten', 'standortdaten'], art9_relevant: [], default_legal_basis: 'consent' },
|
||||
{ id: 'social_media', name: 'Social-Media-Marketing', purpose: 'Betrieb von Unternehmensprofilen und Werbekampagnen', primary_categories: ['kontaktdaten', 'nutzungsdaten', 'kommunikation'], art9_relevant: [], default_legal_basis: 'consent' },
|
||||
{ id: 'consent_management', name: 'Consent-Management (Cookies)', purpose: 'Verwaltung der Einwilligungen f\u00FCr Cookies und Tracking', primary_categories: ['nutzungsdaten'], art9_relevant: [], default_legal_basis: 'consent' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'it', name: 'IT / Administration', icon: '\uD83D\uDDA5\uFE0F',
|
||||
activities: [
|
||||
{ id: 'zugangsverwaltung', name: 'Zugangsverwaltung (IAM)', purpose: 'Verwaltung von Benutzerkonten, Passw\u00F6rtern und Zugriffsrechten', primary_categories: ['beschaeftigtendaten', 'stammdaten', 'nutzungsdaten'], art9_relevant: ['biometrie'], default_legal_basis: 'contract' },
|
||||
{ id: 'email_kommunikation', name: 'E-Mail-Kommunikation', purpose: 'Gesch\u00E4ftliche E-Mail-Korrespondenz', primary_categories: ['kontaktdaten', 'kommunikation', 'stammdaten'], art9_relevant: [], default_legal_basis: 'interest' },
|
||||
{ id: 'datensicherung', name: 'Datensicherung / Backup', purpose: 'Sicherung von Unternehmensdaten zum Schutz vor Datenverlust', primary_categories: ['nutzungsdaten', 'beschaeftigtendaten'], art9_relevant: [], default_legal_basis: 'interest' },
|
||||
{ id: 'website_betrieb', name: 'Website-Betrieb', purpose: 'Bereitstellung der Unternehmenswebsite und Kontaktformulare', primary_categories: ['nutzungsdaten', 'kontaktdaten', 'stammdaten'], art9_relevant: [], default_legal_basis: 'interest', hasServiceProvider: true, legalHint: 'Bei externem Website-Management: AVV nach Art. 28 DSGVO mit dem Dienstleister erforderlich. Cookies, Analytics und Kontaktformulare verarbeiten personenbezogene Daten \u2014 auch wenn der Dienstleister sie technisch betreibt, bleibt Ihr Unternehmen verantwortlich.' },
|
||||
{ id: 'it_sicherheit', name: 'IT-Sicherheit / Logging', purpose: '\u00DCberwachung der IT-Sicherheit, Log-Analyse, Vorfallbehandlung', primary_categories: ['nutzungsdaten', 'beschaeftigtendaten'], art9_relevant: [], default_legal_basis: 'interest' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'recht', name: 'Recht / Compliance', icon: '\u2696\uFE0F',
|
||||
activities: [
|
||||
{ id: 'datenschutzanfragen', name: 'Betroffenenrechte (DSGVO)', purpose: 'Bearbeitung von Auskunfts-, L\u00F6sch- und Berichtigungsanfragen', primary_categories: ['stammdaten', 'kontaktdaten', 'kommunikation'], art9_relevant: [], default_legal_basis: 'legal' },
|
||||
{ id: 'auftragsverarbeitung', name: 'Auftragsverarbeitung (AVV)', purpose: 'Dokumentation und Verwaltung von Auftragsverarbeitungsverh\u00E4ltnissen', primary_categories: ['stammdaten', 'kontaktdaten', 'vertragsdaten'], art9_relevant: [], default_legal_basis: 'legal' },
|
||||
{ id: 'whistleblowing', name: 'Hinweisgebersystem', purpose: 'Entgegennahme und Bearbeitung von Meldungen nach HinSchG', primary_categories: ['stammdaten', 'kontaktdaten', 'kommunikation'], art9_relevant: [], default_legal_basis: 'legal', categoryInfo: { stammdaten: 'Identit\u00E4t des Hinweisgebers (besonders sch\u00FCtzenswert! \u00A7 8 HinSchG Vertraulichkeitsgebot)', kontaktdaten: 'Kontaktdaten nur f\u00FCr zust\u00E4ndige Meldestelle zug\u00E4nglich', kommunikation: 'Meldungsinhalt, Kommunikationsverlauf, Zeugenaussagen. L\u00F6schfrist: 3 Jahre nach Abschluss (\u00A7 11 Abs. 5 HinSchG)' } },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// ── Abteilungen die je nach Kontext relevant sind ──
|
||||
|
||||
const OPTIONAL_DEPARTMENTS: ActivityDepartment[] = [
|
||||
{
|
||||
id: 'einkauf', name: 'Einkauf / Beschaffung', icon: '\uD83D\uDED2',
|
||||
activities: [
|
||||
{ id: 'lieferantenverwaltung', name: 'Lieferantenverwaltung', purpose: 'Erfassung und Pflege von Lieferantenstammdaten', primary_categories: ['stammdaten', 'kontaktdaten', 'vertragsdaten', 'zahlungsdaten'], art9_relevant: [], default_legal_basis: 'contract' },
|
||||
{ id: 'bestellwesen', name: 'Bestellwesen', purpose: 'Abwicklung von Bestellungen bei Lieferanten', primary_categories: ['stammdaten', 'vertragsdaten', 'zahlungsdaten'], art9_relevant: [], default_legal_basis: 'contract' },
|
||||
{ id: 'lieferantenbewertung', name: 'Lieferantenbewertung', purpose: 'Bewertung und Qualifizierung von Lieferanten', primary_categories: ['stammdaten', 'kontaktdaten'], art9_relevant: [], default_legal_basis: 'interest' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'produktion', name: 'Produktion / Fertigung', icon: '\uD83C\uDFED',
|
||||
activities: [
|
||||
{ id: 'produktionsplanung', name: 'Produktionsplanung', purpose: 'Planung und Steuerung von Produktionsprozessen inkl. Personalzuordnung', primary_categories: ['beschaeftigtendaten', 'stammdaten'], art9_relevant: [], default_legal_basis: 'contract' },
|
||||
{ id: 'qualitaetskontrolle', name: 'Qualit\u00E4tskontrolle', purpose: 'Pr\u00FCfung und Dokumentation der Produktqualit\u00E4t', primary_categories: ['beschaeftigtendaten', 'stammdaten'], art9_relevant: [], default_legal_basis: 'contract' },
|
||||
{ id: 'arbeitssicherheit', name: 'Arbeitssicherheit / Arbeitsschutz', purpose: 'Dokumentation von Arbeitsschutzma\u00DFnahmen, Unf\u00E4llen, Gef\u00E4hrdungsbeurteilungen', primary_categories: ['beschaeftigtendaten'], art9_relevant: ['gesundheit'], default_legal_basis: 'legal' },
|
||||
{ id: 'schichtplanung', name: 'Schichtplanung', purpose: 'Erstellung und Verwaltung von Schichtpl\u00E4nen', primary_categories: ['beschaeftigtendaten'], art9_relevant: [], default_legal_basis: 'contract' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'logistik', name: 'Logistik / Versand', icon: '\uD83D\uDE9A',
|
||||
activities: [
|
||||
{ id: 'versandabwicklung', name: 'Versandabwicklung', purpose: 'Verarbeitung von Empf\u00E4nger- und Versanddaten f\u00FCr den Warenversand', primary_categories: ['stammdaten', 'kontaktdaten', 'standortdaten'], art9_relevant: [], default_legal_basis: 'contract' },
|
||||
{ id: 'lieferverfolgung', name: 'Lieferverfolgung / Sendungstracking', purpose: 'Nachverfolgung von Sendungen und Zustellung', primary_categories: ['stammdaten', 'kontaktdaten', 'standortdaten'], art9_relevant: [], default_legal_basis: 'contract' },
|
||||
{ id: 'lagerverwaltung', name: 'Lagerverwaltung', purpose: 'Verwaltung von Lagerbest\u00E4nden und Warenbewegungen', primary_categories: ['stammdaten', 'beschaeftigtendaten'], art9_relevant: [], default_legal_basis: 'contract' },
|
||||
{ id: 'retouren', name: 'Retourenmanagement', purpose: 'Bearbeitung von Warenr\u00FCcksendungen', primary_categories: ['stammdaten', 'kontaktdaten', 'vertragsdaten'], art9_relevant: [], default_legal_basis: 'contract' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'kundenservice', name: 'Kundenservice / Support', icon: '\uD83C\uDFA7',
|
||||
activities: [
|
||||
{ id: 'ticketsystem', name: 'Ticketsystem / Support', purpose: 'Erfassung und Bearbeitung von Kundenanfragen und Supportf\u00E4llen', primary_categories: ['stammdaten', 'kontaktdaten', 'kommunikation'], art9_relevant: [], default_legal_basis: 'contract' },
|
||||
{ id: 'beschwerdemanagement', name: 'Beschwerdemanagement', purpose: 'Bearbeitung und Dokumentation von Kundenbeschwerden', primary_categories: ['stammdaten', 'kontaktdaten', 'kommunikation'], art9_relevant: [], default_legal_basis: 'contract' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'facility', name: 'Facility Management', icon: '\uD83C\uDFE2',
|
||||
activities: [
|
||||
{ id: 'zutrittskontrolle', name: 'Zutrittskontrolle', purpose: 'Kontrolle und Protokollierung des Zutritts zu Geb\u00E4uden und R\u00E4umen', primary_categories: ['beschaeftigtendaten', 'stammdaten', 'bilddaten'], art9_relevant: ['biometrie'], default_legal_basis: 'interest' },
|
||||
{ id: 'videoueberwachung', name: 'Video\u00FCberwachung', purpose: '\u00DCberwachung von Geb\u00E4uden und Gel\u00E4nden mittels Videokameras', primary_categories: ['bilddaten', 'beschaeftigtendaten'], art9_relevant: ['biometrie'], default_legal_basis: 'interest', categoryInfo: { bilddaten: 'Videoaufzeichnungen von Kameras. Speicherdauer: empfohlen max. 72h (BeschDG-Entwurf). Datenschutzhinweis-Schilder (Art. 13 DSGVO) sind Pflicht. Betriebsrat hat Mitbestimmungsrecht (\u00A7 87 Abs. 1 Nr. 6 BetrVG)' } },
|
||||
{ id: 'besuchermanagement', name: 'Besuchermanagement', purpose: 'Erfassung und Verwaltung von Besucherdaten', primary_categories: ['stammdaten', 'kontaktdaten'], art9_relevant: [], default_legal_basis: 'interest' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// ── Branchenspezifische Abteilungen ──
|
||||
|
||||
const INDUSTRY_DEPARTMENTS: Record<string, ActivityDepartment[]> = {
|
||||
'E-Commerce / Handel': [{
|
||||
id: 'ecommerce', name: 'E-Commerce / Webshop', icon: '\uD83D\uDECD\uFE0F',
|
||||
activities: [
|
||||
{ id: 'bestellabwicklung', name: 'Bestellabwicklung (Webshop)', purpose: 'Verarbeitung von Kundenbestellungen im Online-Shop', primary_categories: ['stammdaten', 'kontaktdaten', 'zahlungsdaten', 'vertragsdaten'], art9_relevant: [], default_legal_basis: 'contract' },
|
||||
{ id: 'kundenkonto', name: 'Kundenkonto-Verwaltung', purpose: 'Verwaltung registrierter Kundenkonten im Online-Shop', primary_categories: ['stammdaten', 'kontaktdaten', 'nutzungsdaten', 'zahlungsdaten'], art9_relevant: [], default_legal_basis: 'contract' },
|
||||
{ id: 'webshop_analyse', name: 'Webshop-Analyse / Conversion', purpose: 'Analyse des Kaufverhaltens und Conversion-Rates', primary_categories: ['nutzungsdaten', 'standortdaten'], art9_relevant: [], default_legal_basis: 'consent' },
|
||||
{ id: 'produktbewertungen', name: 'Produktbewertungen / Reviews', purpose: 'Verwaltung von Kundenrezensionen und Produktbewertungen', primary_categories: ['stammdaten', 'kontaktdaten', 'kommunikation'], art9_relevant: [], default_legal_basis: 'consent' },
|
||||
],
|
||||
}],
|
||||
'Gesundheitswesen': [{
|
||||
id: 'gesundheit_dept', name: 'Medizin / Patientenversorgung', icon: '\uD83C\uDFE5',
|
||||
activities: [
|
||||
{ id: 'patientenverwaltung', name: 'Patientenverwaltung', purpose: 'Verwaltung von Patientenstammdaten und Krankengeschichte', primary_categories: ['stammdaten', 'kontaktdaten', 'zahlungsdaten'], art9_relevant: ['gesundheit', 'genetik'], default_legal_basis: 'contract' },
|
||||
{ id: 'terminplanung_med', name: 'Terminplanung (Patienten)', purpose: 'Vergabe und Verwaltung von Patiententerminen', primary_categories: ['stammdaten', 'kontaktdaten'], art9_relevant: ['gesundheit'], default_legal_basis: 'contract' },
|
||||
{ id: 'kv_abrechnung', name: 'KV-Abrechnung', purpose: 'Abrechnung von Leistungen gegen\u00FCber Kassen\u00E4rztlichen Vereinigungen', primary_categories: ['stammdaten', 'zahlungsdaten'], art9_relevant: ['gesundheit'], default_legal_basis: 'legal' },
|
||||
{ id: 'med_dokumentation', name: 'Medizinische Dokumentation', purpose: 'Dokumentation von Diagnosen, Therapien und Behandlungsverl\u00E4ufen', primary_categories: ['stammdaten'], art9_relevant: ['gesundheit', 'genetik'], default_legal_basis: 'legal' },
|
||||
],
|
||||
}],
|
||||
'Finanzdienstleistungen': [{
|
||||
id: 'finanz_dept', name: 'Regulatorik / Finanzaufsicht', icon: '\uD83C\uDFE6',
|
||||
activities: [
|
||||
{ id: 'kyc', name: 'Know Your Customer (KYC)', purpose: 'Identifizierung und Verifizierung von Kunden gem\u00E4\u00DF GwG', primary_categories: ['stammdaten', 'kontaktdaten', 'bilddaten'], art9_relevant: [], default_legal_basis: 'legal' },
|
||||
{ id: 'kontoverwaltung', name: 'Kontoverwaltung', purpose: 'Verwaltung von Kundenkonten und Kontobewegungen', primary_categories: ['stammdaten', 'kontaktdaten', 'zahlungsdaten'], art9_relevant: [], default_legal_basis: 'contract' },
|
||||
{ id: 'geldwaeschepraevention', name: 'Geldw\u00E4schepr\u00E4vention (AML)', purpose: '\u00DCberwachung verd\u00E4chtiger Transaktionen nach GwG', primary_categories: ['stammdaten', 'zahlungsdaten'], art9_relevant: [], default_legal_basis: 'legal' },
|
||||
],
|
||||
}],
|
||||
'Bildung': [{
|
||||
id: 'bildung_dept', name: 'Bildung / Lehre', icon: '\uD83C\uDF93',
|
||||
activities: [
|
||||
{ id: 'schuelerverwaltung', name: 'Sch\u00FCler-/Teilnehmerverwaltung', purpose: 'Verwaltung von Lernenden, Noten, Anwesenheit', primary_categories: ['stammdaten', 'kontaktdaten', 'nutzungsdaten'], art9_relevant: ['gesundheit', 'religion'], default_legal_basis: 'contract' },
|
||||
{ id: 'lernplattform', name: 'Lernplattform / LMS', purpose: 'Bereitstellung und Nutzung digitaler Lernplattformen', primary_categories: ['stammdaten', 'nutzungsdaten', 'kommunikation'], art9_relevant: [], default_legal_basis: 'contract' },
|
||||
{ id: 'pruefungsverwaltung', name: 'Pr\u00FCfungsverwaltung', purpose: 'Verwaltung und Dokumentation von Pr\u00FCfungen und Noten', primary_categories: ['stammdaten'], art9_relevant: [], default_legal_basis: 'contract' },
|
||||
],
|
||||
}],
|
||||
'Immobilien': [{
|
||||
id: 'immobilien_dept', name: 'Immobilienverwaltung', icon: '\uD83C\uDFE0',
|
||||
activities: [
|
||||
{ id: 'mieterverwaltung', name: 'Mieterverwaltung', purpose: 'Verwaltung von Mietvertr\u00E4gen und Mieterdaten', primary_categories: ['stammdaten', 'kontaktdaten', 'vertragsdaten', 'zahlungsdaten'], art9_relevant: [], default_legal_basis: 'contract' },
|
||||
{ id: 'nebenkostenabrechnung', name: 'Nebenkostenabrechnung', purpose: 'Erstellung und Versand von Nebenkostenabrechnungen', primary_categories: ['stammdaten', 'zahlungsdaten'], art9_relevant: [], default_legal_basis: 'contract' },
|
||||
],
|
||||
}],
|
||||
}
|
||||
|
||||
// Compute which departments to show based on company context
|
||||
export function getRelevantDepartments(industry: string | string[], businessModel: string | undefined, companySize: string | undefined): ActivityDepartment[] {
|
||||
const departments: ActivityDepartment[] = [...UNIVERSAL_DEPARTMENTS]
|
||||
|
||||
// Always show optional departments
|
||||
departments.push(...OPTIONAL_DEPARTMENTS)
|
||||
|
||||
// Add industry-specific departments (support multi-select)
|
||||
const industries = Array.isArray(industry) ? industry : [industry]
|
||||
const addedIds = new Set<string>()
|
||||
for (const ind of industries) {
|
||||
const industryDepts = INDUSTRY_DEPARTMENTS[ind]
|
||||
if (industryDepts) {
|
||||
for (const dept of industryDepts) {
|
||||
if (!addedIds.has(dept.id)) {
|
||||
addedIds.add(dept.id)
|
||||
departments.push(dept)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return departments
|
||||
}
|
||||
|
||||
// Helper: find template for an activity ID across all departments
|
||||
export function findTemplate(departments: ActivityDepartment[], activityId: string) {
|
||||
for (const dept of departments) {
|
||||
const t = dept.activities.find(a => a.id === activityId)
|
||||
if (t) return t
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { AISystemTemplate } from './types'
|
||||
|
||||
export const AI_SYSTEM_TEMPLATES: { category: string; icon: string; systems: AISystemTemplate[] }[] = [
|
||||
{
|
||||
category: 'Text-KI / Chatbots',
|
||||
icon: '\uD83D\uDCAC',
|
||||
systems: [
|
||||
{ id: 'chatgpt', name: 'ChatGPT', vendor: 'OpenAI', category: 'Text-KI / Chatbots', icon: '\uD83D\uDCAC', typicalPurposes: ['Textgenerierung', 'Kundensupport', 'Zusammenfassungen', 'Recherche'], dataWarning: 'Datenverarbeitung in den USA. Eingaben koennen fuer Training verwendet werden (opt-out moeglich).', processes_personal_data_likely: true },
|
||||
{ id: 'claude', name: 'Claude', vendor: 'Anthropic', category: 'Text-KI / Chatbots', icon: '\uD83D\uDCAC', typicalPurposes: ['Textgenerierung', 'Analyse', 'Zusammenfassungen', 'Code-Review'], dataWarning: 'Datenverarbeitung in den USA. Eingaben werden NICHT fuer Training verwendet.', processes_personal_data_likely: true },
|
||||
{ id: 'gemini', name: 'Google Gemini', vendor: 'Google', category: 'Text-KI / Chatbots', icon: '\uD83D\uDCAC', typicalPurposes: ['Textgenerierung', 'Recherche', 'Zusammenfassungen'], dataWarning: 'Datenverarbeitung in den USA/EU je nach Einstellung.', processes_personal_data_likely: true },
|
||||
{ id: 'perplexity', name: 'Perplexity', vendor: 'Perplexity AI', category: 'Text-KI / Chatbots', icon: '\uD83D\uDCAC', typicalPurposes: ['Websuche mit KI', 'Recherche', 'Zusammenfassungen'], dataWarning: 'Websuche + KI. Eingaben werden verarbeitet.', processes_personal_data_likely: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: 'Office / Produktivitaet',
|
||||
icon: '\uD83D\uDCCE',
|
||||
systems: [
|
||||
{ id: 'copilot365', name: 'Microsoft 365 Copilot', vendor: 'Microsoft', category: 'Office / Produktivitaet', icon: '\uD83D\uDCCE', typicalPurposes: ['E-Mail-Entwuerfe', 'Dokument-Zusammenfassung', 'Praesentationen', 'Excel-Analysen'], dataWarning: 'In M365-Tenant integriert. Daten bleiben im Tenant, aber: KI-Verarbeitung ggf. in den USA.', processes_personal_data_likely: true },
|
||||
{ id: 'google-workspace-ai', name: 'Google Workspace AI', vendor: 'Google', category: 'Office / Produktivitaet', icon: '\uD83D\uDCCE', typicalPurposes: ['E-Mail-Entwuerfe', 'Dokument-Zusammenfassung', 'Tabellen-Analysen'], dataWarning: 'Duet AI in Docs, Sheets, Gmail. Datenverarbeitung je nach Workspace-Region.', processes_personal_data_likely: true },
|
||||
{ id: 'notion-ai', name: 'Notion AI', vendor: 'Notion', category: 'Office / Produktivitaet', icon: '\uD83D\uDCCE', typicalPurposes: ['Texterstellung', 'Zusammenfassungen', 'Aufgabenverwaltung'], dataWarning: 'Datenverarbeitung in den USA.', processes_personal_data_likely: false },
|
||||
{ id: 'grammarly', name: 'Grammarly', vendor: 'Grammarly', category: 'Office / Produktivitaet', icon: '\uD83D\uDCCE', typicalPurposes: ['Textkorrektur', 'Stiloptimierung', 'Tonalitaet'], dataWarning: 'Textanalyse, Datenverarbeitung in den USA.', processes_personal_data_likely: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: 'Code-Assistenz',
|
||||
icon: '\uD83D\uDCBB',
|
||||
systems: [
|
||||
{ id: 'github-copilot', name: 'GitHub Copilot', vendor: 'Microsoft/GitHub', category: 'Code-Assistenz', icon: '\uD83D\uDCBB', typicalPurposes: ['Code-Vorschlaege', 'Code-Generierung', 'Dokumentation'], dataWarning: 'Code-Vorschlaege basierend auf Kontext. Code-Snippets werden verarbeitet.', processes_personal_data_likely: false },
|
||||
{ id: 'cursor', name: 'Cursor / Windsurf', vendor: 'Cursor Inc.', category: 'Code-Assistenz', icon: '\uD83D\uDCBB', typicalPurposes: ['Code-Generierung', 'Refactoring', 'Debugging'], dataWarning: 'KI-Code-Editor. Code wird an KI-Backend uebermittelt.', processes_personal_data_likely: false },
|
||||
{ id: 'codewhisperer', name: 'Amazon CodeWhisperer', vendor: 'AWS', category: 'Code-Assistenz', icon: '\uD83D\uDCBB', typicalPurposes: ['Code-Vorschlaege', 'Sicherheits-Scans'], dataWarning: 'Code-Vorschlaege. Opt-out fuer Code-Sharing moeglich.', processes_personal_data_likely: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: 'Bildgenerierung',
|
||||
icon: '\uD83C\uDFA8',
|
||||
systems: [
|
||||
{ id: 'dalle', name: 'DALL-E / ChatGPT Bildgenerierung', vendor: 'OpenAI', category: 'Bildgenerierung', icon: '\uD83C\uDFA8', typicalPurposes: ['Bildgenerierung', 'Marketing-Material', 'Illustrationen'], dataWarning: 'Bildgenerierung. Prompts werden verarbeitet.', processes_personal_data_likely: false },
|
||||
{ id: 'midjourney', name: 'Midjourney', vendor: 'Midjourney Inc.', category: 'Bildgenerierung', icon: '\uD83C\uDFA8', typicalPurposes: ['Bildgenerierung', 'Design-Konzepte', 'Illustrationen'], dataWarning: 'Bildgenerierung via Discord. Prompts sind oeffentlich sichtbar (ausser Pro-Plan).', processes_personal_data_likely: false },
|
||||
{ id: 'firefly', name: 'Adobe Firefly', vendor: 'Adobe', category: 'Bildgenerierung', icon: '\uD83C\uDFA8', typicalPurposes: ['Bildgenerierung', 'Bildbearbeitung', 'Design'], dataWarning: 'In Creative Cloud integriert. Trainiert auf lizenzierten Inhalten.', processes_personal_data_likely: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: 'Uebersetzung / Sprache',
|
||||
icon: '\uD83C\uDF10',
|
||||
systems: [
|
||||
{ id: 'deepl', name: 'DeepL', vendor: 'DeepL SE', category: 'Uebersetzung / Sprache', icon: '\uD83C\uDF10', typicalPurposes: ['Uebersetzung', 'Dokumentenuebersetzung'], dataWarning: 'Deutscher Anbieter, Server in EU. DeepL Pro: Texte werden NICHT gespeichert.', processes_personal_data_likely: false },
|
||||
{ id: 'deepl-write', name: 'DeepL Write', vendor: 'DeepL SE', category: 'Uebersetzung / Sprache', icon: '\uD83C\uDF10', typicalPurposes: ['Textoptimierung', 'Stilverbesserung'], dataWarning: 'Deutscher Anbieter, Server in EU. Gleiche Datenschutz-Bedingungen wie DeepL.', processes_personal_data_likely: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: 'CRM / Sales KI',
|
||||
icon: '\uD83D\uDCCA',
|
||||
systems: [
|
||||
{ id: 'salesforce-einstein', name: 'Salesforce Einstein', vendor: 'Salesforce', category: 'CRM / Sales KI', icon: '\uD83D\uDCCA', typicalPurposes: ['Lead-Scoring', 'Prognosen', 'Empfehlungen'], dataWarning: 'In Salesforce integriert. Verarbeitet CRM-Daten.', processes_personal_data_likely: true },
|
||||
{ id: 'hubspot-ai', name: 'HubSpot AI', vendor: 'HubSpot', category: 'CRM / Sales KI', icon: '\uD83D\uDCCA', typicalPurposes: ['E-Mail-Generierung', 'Lead-Scoring', 'Content-Erstellung'], dataWarning: 'KI-Features in HubSpot CRM. Datenverarbeitung in USA/EU.', processes_personal_data_likely: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: 'Interne / Eigene Systeme',
|
||||
icon: '\uD83C\uDFE2',
|
||||
systems: [
|
||||
{ id: 'internal-ai', name: 'Eigenes KI-System', vendor: 'Intern', category: 'Interne / Eigene Systeme', icon: '\uD83C\uDFE2', typicalPurposes: ['Interne Analyse', 'Automatisierung', 'Prozessoptimierung'], dataWarning: undefined, processes_personal_data_likely: false },
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,139 @@
|
||||
import { LegalForm } from '@/lib/sdk/types'
|
||||
import { OfferingType } from '@/lib/sdk/types'
|
||||
|
||||
// =============================================================================
|
||||
// WIZARD STEPS
|
||||
// =============================================================================
|
||||
|
||||
export const BASE_WIZARD_STEPS = [
|
||||
{ id: 1, name: 'Basisinfos', description: 'Firmenname und Rechtsform' },
|
||||
{ id: 2, name: 'Geschaeftsmodell', description: 'B2B, B2C und Angebote' },
|
||||
{ id: 3, name: 'Firmengroesse', description: 'Mitarbeiter und Umsatz' },
|
||||
{ id: 4, name: 'Standorte', description: 'Hauptsitz und Zielmaerkte' },
|
||||
{ id: 5, name: 'Datenschutz', description: 'Rollen und DSB' },
|
||||
{ id: 6, name: 'Zertifizierungen & Kontakte', description: 'Bestehende und angestrebte Zertifizierungen' },
|
||||
]
|
||||
|
||||
export const MACHINE_BUILDER_STEP = { id: 7, name: 'Produkt & Maschine', description: 'Software, KI und CE in Ihrem Produkt' }
|
||||
|
||||
// =============================================================================
|
||||
// INDUSTRIES
|
||||
// =============================================================================
|
||||
|
||||
export const INDUSTRIES = [
|
||||
'Technologie / IT',
|
||||
'IT Dienstleistungen',
|
||||
'E-Commerce / Handel',
|
||||
'Finanzdienstleistungen',
|
||||
'Versicherungen',
|
||||
'Gesundheitswesen',
|
||||
'Pharma',
|
||||
'Bildung',
|
||||
'Beratung / Consulting',
|
||||
'Marketing / Agentur',
|
||||
'Produktion / Industrie',
|
||||
'Logistik / Transport',
|
||||
'Immobilien',
|
||||
'Bau',
|
||||
'Energie',
|
||||
'Automobil',
|
||||
'Luft- und Raumfahrt',
|
||||
'Maschinenbau',
|
||||
'Anlagenbau',
|
||||
'Automatisierung',
|
||||
'Robotik',
|
||||
'Messtechnik',
|
||||
'Agrar',
|
||||
'Chemie',
|
||||
'Minen / Bergbau',
|
||||
'Telekommunikation',
|
||||
'Medien / Verlage',
|
||||
'Gastronomie / Hotellerie',
|
||||
'Recht / Kanzlei',
|
||||
'Oeffentlicher Dienst',
|
||||
'Sonstige',
|
||||
]
|
||||
|
||||
const MACHINE_BUILDER_INDUSTRIES = [
|
||||
'Maschinenbau',
|
||||
'Anlagenbau',
|
||||
'Automatisierung',
|
||||
'Robotik',
|
||||
'Messtechnik',
|
||||
]
|
||||
|
||||
export const isMachineBuilderIndustry = (industry: string | string[]) => {
|
||||
const industries = Array.isArray(industry) ? industry : [industry]
|
||||
return industries.some(i => MACHINE_BUILDER_INDUSTRIES.includes(i))
|
||||
}
|
||||
|
||||
export function getWizardSteps(industry: string | string[]) {
|
||||
if (isMachineBuilderIndustry(industry)) {
|
||||
return [...BASE_WIZARD_STEPS, MACHINE_BUILDER_STEP]
|
||||
}
|
||||
return BASE_WIZARD_STEPS
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// LEGAL FORMS
|
||||
// =============================================================================
|
||||
|
||||
export const LEGAL_FORM_LABELS: Record<LegalForm, string> = {
|
||||
einzelunternehmen: 'Einzelunternehmen',
|
||||
gbr: 'GbR',
|
||||
ohg: 'OHG',
|
||||
kg: 'KG',
|
||||
gmbh: 'GmbH',
|
||||
ug: 'UG (haftungsbeschränkt)',
|
||||
ag: 'AG',
|
||||
gmbh_co_kg: 'GmbH & Co. KG',
|
||||
ev: 'e.V. (Verein)',
|
||||
stiftung: 'Stiftung',
|
||||
other: 'Sonstige',
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// STEP EXPLANATIONS
|
||||
// =============================================================================
|
||||
|
||||
export const STEP_EXPLANATIONS: Record<number, string> = {
|
||||
1: 'Rechtsform und Gründungsjahr bestimmen, welche Meldepflichten und Schwellenwerte für Ihr Unternehmen gelten (z.B. NIS2, AI Act).',
|
||||
2: 'Ihr Geschäftsmodell und Ihre Angebote bestimmen, welche DSGVO-Pflichten greifen: B2C erfordert z.B. strengere Einwilligungsregeln, Webshops brauchen Cookie-Banner und Datenschutzerklärungen, SaaS-Angebote eine Auftragsverarbeitung.',
|
||||
3: 'Die Unternehmensgröße bestimmt, ob Sie einen DSB benennen müssen (ab 20 MA), ob NIS2-Pflichten greifen und welche Audit-Anforderungen gelten.',
|
||||
4: 'Standorte und Zielmärkte bestimmen, welche nationalen Datenschutzgesetze zusätzlich zur DSGVO greifen (z.B. BDSG, DSG-AT, UK GDPR, CCPA).',
|
||||
5: 'Ob Sie Verantwortlicher oder Auftragsverarbeiter sind, bestimmt Ihre DSGVO-Pflichten grundlegend.',
|
||||
6: 'Regulierungsrahmen und Prüfzyklen definieren, welche Compliance-Module für Sie aktiviert werden und in welchem Rhythmus Audits stattfinden.',
|
||||
7: 'Als Maschinenbauer gelten zusätzliche Anforderungen: CE-Kennzeichnung, Maschinenverordnung, Produktsicherheit und ggf. Hochrisiko-KI im Sinne des AI Act.',
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// OFFERING URL CONFIG
|
||||
// =============================================================================
|
||||
|
||||
export const OFFERING_URL_CONFIG: Partial<Record<OfferingType, { label: string; placeholder: string; hint: string }>> = {
|
||||
website: { label: 'Website-Domain', placeholder: 'https://www.beispiel.de', hint: 'Ihre Unternehmenswebsite' },
|
||||
webshop: { label: 'Online-Shop URL', placeholder: 'https://shop.beispiel.de', hint: 'URL zu Ihrem Online-Shop' },
|
||||
app_mobile: { label: 'App-Store Links', placeholder: 'https://apps.apple.com/... oder https://play.google.com/...', hint: 'Apple App Store und/oder Google Play Store Link' },
|
||||
software_saas: { label: 'SaaS-Portal URL', placeholder: 'https://app.beispiel.de', hint: 'Login-/Registrierungsseite Ihres Kundenportals' },
|
||||
app_web: { label: 'Web-App URL', placeholder: 'https://app.beispiel.de', hint: 'URL zu Ihrer Web-Anwendung' },
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CERTIFICATIONS
|
||||
// =============================================================================
|
||||
|
||||
export const CERTIFICATIONS = [
|
||||
{ id: 'iso27001', label: 'ISO 27001', desc: 'Informationssicherheits-Managementsystem' },
|
||||
{ id: 'iso27701', label: 'ISO 27701', desc: 'Datenschutz-Managementsystem' },
|
||||
{ id: 'iso9001', label: 'ISO 9001', desc: 'Qualitaetsmanagement' },
|
||||
{ id: 'iso14001', label: 'ISO 14001', desc: 'Umweltmanagement' },
|
||||
{ id: 'iso22301', label: 'ISO 22301', desc: 'Business Continuity Management' },
|
||||
{ id: 'iso42001', label: 'ISO 42001', desc: 'KI-Managementsystem' },
|
||||
{ id: 'tisax', label: 'TISAX', desc: 'Trusted Information Security Assessment Exchange (Automotive)' },
|
||||
{ id: 'soc2', label: 'SOC 2', desc: 'Service Organization Controls (Typ I/II)' },
|
||||
{ id: 'c5', label: 'C5', desc: 'Cloud Computing Compliance Criteria Catalogue (BSI)' },
|
||||
{ id: 'bsi_grundschutz', label: 'BSI IT-Grundschutz', desc: 'IT-Grundschutz-Zertifikat oder Testat' },
|
||||
{ id: 'pci_dss', label: 'PCI DSS', desc: 'Payment Card Industry Data Security Standard' },
|
||||
{ id: 'hipaa', label: 'HIPAA', desc: 'Health Insurance Portability and Accountability Act' },
|
||||
{ id: 'other', label: 'Sonstige', desc: 'Andere Zertifizierungen' },
|
||||
]
|
||||
@@ -0,0 +1,59 @@
|
||||
export interface ProcessingActivity {
|
||||
id: string
|
||||
name: string
|
||||
purpose: string
|
||||
data_categories: string[]
|
||||
legal_basis: string
|
||||
department?: string
|
||||
custom?: boolean
|
||||
usesServiceProvider?: boolean
|
||||
serviceProviderName?: string
|
||||
}
|
||||
|
||||
export interface AISystem {
|
||||
id: string
|
||||
name: string
|
||||
vendor: string
|
||||
purpose: string
|
||||
purposes?: string[]
|
||||
processes_personal_data: boolean
|
||||
isCustom?: boolean
|
||||
notes?: string
|
||||
}
|
||||
|
||||
export interface CertificationEntry {
|
||||
certId: string
|
||||
certifier?: string
|
||||
lastDate?: string
|
||||
customName?: string
|
||||
}
|
||||
|
||||
export interface ActivityTemplate {
|
||||
id: string
|
||||
name: string
|
||||
purpose: string
|
||||
primary_categories: string[]
|
||||
art9_relevant: string[]
|
||||
default_legal_basis: string
|
||||
legalHint?: string
|
||||
hasServiceProvider?: boolean
|
||||
categoryInfo?: Record<string, string>
|
||||
}
|
||||
|
||||
export interface ActivityDepartment {
|
||||
id: string
|
||||
name: string
|
||||
icon: string
|
||||
activities: ActivityTemplate[]
|
||||
}
|
||||
|
||||
export interface AISystemTemplate {
|
||||
id: string
|
||||
name: string
|
||||
vendor: string
|
||||
category: string
|
||||
icon: string
|
||||
typicalPurposes: string[]
|
||||
dataWarning?: string
|
||||
processes_personal_data_likely: boolean
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import React from 'react'
|
||||
import { CompanyProfile } from '@/lib/sdk/types'
|
||||
import { useSDK } from '@/lib/sdk'
|
||||
import { isMachineBuilderIndustry, getWizardSteps } from '../_components/constants'
|
||||
|
||||
const INITIAL_FORM_DATA: Partial<CompanyProfile> = {
|
||||
companyName: '', legalForm: undefined, industry: [], industryOther: '', foundedYear: null,
|
||||
businessModel: undefined, offerings: [], offeringUrls: {},
|
||||
companySize: undefined, employeeCount: '', annualRevenue: '',
|
||||
headquartersCountry: 'DE', headquartersCountryOther: '', headquartersStreet: '',
|
||||
headquartersZip: '', headquartersCity: '', headquartersState: '',
|
||||
hasInternationalLocations: false, internationalCountries: [], targetMarkets: [],
|
||||
primaryJurisdiction: 'DE', isDataController: true, isDataProcessor: false,
|
||||
dpoName: null, dpoEmail: null, legalContactName: null, legalContactEmail: null,
|
||||
isComplete: false, completedAt: null,
|
||||
}
|
||||
|
||||
function buildProfilePayload(formData: Partial<CompanyProfile>, projectId: string | null, isComplete: boolean) {
|
||||
return {
|
||||
project_id: projectId || null,
|
||||
company_name: formData.companyName || '',
|
||||
legal_form: formData.legalForm || 'GmbH',
|
||||
industry: formData.industry || [],
|
||||
industry_other: formData.industryOther || '',
|
||||
founded_year: formData.foundedYear || null,
|
||||
business_model: formData.businessModel || 'B2B',
|
||||
offerings: formData.offerings || [],
|
||||
offering_urls: formData.offeringUrls || {},
|
||||
company_size: formData.companySize || 'small',
|
||||
employee_count: formData.employeeCount || '',
|
||||
annual_revenue: formData.annualRevenue || '',
|
||||
headquarters_country: formData.headquartersCountry || 'DE',
|
||||
headquarters_country_other: formData.headquartersCountryOther || '',
|
||||
headquarters_street: formData.headquartersStreet || '',
|
||||
headquarters_zip: formData.headquartersZip || '',
|
||||
headquarters_city: formData.headquartersCity || '',
|
||||
headquarters_state: formData.headquartersState || '',
|
||||
has_international_locations: formData.hasInternationalLocations || false,
|
||||
international_countries: formData.internationalCountries || [],
|
||||
target_markets: formData.targetMarkets || [],
|
||||
primary_jurisdiction: formData.primaryJurisdiction || 'DE',
|
||||
is_data_controller: formData.isDataController ?? true,
|
||||
is_data_processor: formData.isDataProcessor ?? false,
|
||||
dpo_name: formData.dpoName || '',
|
||||
dpo_email: formData.dpoEmail || '',
|
||||
is_complete: isComplete,
|
||||
processing_systems: (formData as any).processingSystems || [],
|
||||
ai_systems: (formData as any).aiSystems || [],
|
||||
technical_contacts: (formData as any).technicalContacts || [],
|
||||
existing_certifications: (formData as any).existingCertifications || [],
|
||||
target_certifications: (formData as any).targetCertifications || [],
|
||||
target_certification_other: (formData as any).targetCertificationOther || '',
|
||||
review_cycle_months: (formData as any).reviewCycleMonths || 12,
|
||||
repos: (formData as any).repos || [],
|
||||
document_sources: (formData as any).documentSources || [],
|
||||
...(formData.machineBuilder ? {
|
||||
machine_builder: {
|
||||
product_types: formData.machineBuilder.productTypes || [],
|
||||
product_description: formData.machineBuilder.productDescription || '',
|
||||
product_pride: formData.machineBuilder.productPride || '',
|
||||
contains_software: formData.machineBuilder.containsSoftware || false,
|
||||
contains_firmware: formData.machineBuilder.containsFirmware || false,
|
||||
contains_ai: formData.machineBuilder.containsAI || false,
|
||||
ai_integration_type: formData.machineBuilder.aiIntegrationType || [],
|
||||
has_safety_function: formData.machineBuilder.hasSafetyFunction || false,
|
||||
safety_function_description: formData.machineBuilder.safetyFunctionDescription || '',
|
||||
autonomous_behavior: formData.machineBuilder.autonomousBehavior || false,
|
||||
human_oversight_level: formData.machineBuilder.humanOversightLevel || 'full',
|
||||
is_networked: formData.machineBuilder.isNetworked || false,
|
||||
has_remote_access: formData.machineBuilder.hasRemoteAccess || false,
|
||||
has_ota_updates: formData.machineBuilder.hasOTAUpdates || false,
|
||||
update_mechanism: formData.machineBuilder.updateMechanism || '',
|
||||
export_markets: formData.machineBuilder.exportMarkets || [],
|
||||
critical_sector_clients: formData.machineBuilder.criticalSectorClients || false,
|
||||
critical_sectors: formData.machineBuilder.criticalSectors || [],
|
||||
oem_clients: formData.machineBuilder.oemClients || false,
|
||||
ce_marking_required: formData.machineBuilder.ceMarkingRequired || false,
|
||||
existing_ce_process: formData.machineBuilder.existingCEProcess || false,
|
||||
has_risk_assessment: formData.machineBuilder.hasRiskAssessment || false,
|
||||
},
|
||||
} : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export function useCompanyProfileForm() {
|
||||
const { state, dispatch, setCompanyProfile, goToNextStep, projectId } = useSDK()
|
||||
const [currentStep, setCurrentStep] = useState(1)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [formData, setFormData] = useState<Partial<CompanyProfile>>(INITIAL_FORM_DATA)
|
||||
const [draftSaveStatus, setDraftSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
|
||||
|
||||
const showMachineBuilderStep = isMachineBuilderIndustry(formData.industry || [])
|
||||
const wizardSteps = getWizardSteps(formData.industry || [])
|
||||
const lastStep = wizardSteps[wizardSteps.length - 1].id
|
||||
|
||||
const profileApiUrl = (extra?: string) => {
|
||||
const params = new URLSearchParams()
|
||||
if (projectId) params.set('project_id', projectId)
|
||||
const qs = params.toString()
|
||||
const base = '/api/sdk/v1/company-profile' + (extra || '')
|
||||
return qs ? `${base}?${qs}` : base
|
||||
}
|
||||
|
||||
// Load existing profile
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
async function loadFromBackend() {
|
||||
try {
|
||||
const apiUrl = '/api/sdk/v1/company-profile' + (projectId ? `?project_id=${encodeURIComponent(projectId)}` : '')
|
||||
const response = await fetch(apiUrl)
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
if (data && !cancelled) {
|
||||
const backendProfile: Partial<CompanyProfile> = {
|
||||
companyName: data.company_name || '', legalForm: data.legal_form || undefined,
|
||||
industry: Array.isArray(data.industry) ? data.industry : (data.industry ? [data.industry] : []),
|
||||
industryOther: data.industry_other || '', foundedYear: data.founded_year || undefined,
|
||||
businessModel: data.business_model || undefined, offerings: data.offerings || [],
|
||||
offeringUrls: data.offering_urls || {}, companySize: data.company_size || undefined,
|
||||
employeeCount: data.employee_count || '', annualRevenue: data.annual_revenue || '',
|
||||
headquartersCountry: data.headquarters_country || 'DE',
|
||||
headquartersCountryOther: data.headquarters_country_other || '',
|
||||
headquartersStreet: data.headquarters_street || '',
|
||||
headquartersZip: data.headquarters_zip || '', headquartersCity: data.headquarters_city || '',
|
||||
headquartersState: data.headquarters_state || '',
|
||||
hasInternationalLocations: data.has_international_locations || false,
|
||||
internationalCountries: data.international_countries || [],
|
||||
targetMarkets: data.target_markets || [], primaryJurisdiction: data.primary_jurisdiction || 'DE',
|
||||
isDataController: data.is_data_controller ?? true, isDataProcessor: data.is_data_processor ?? false,
|
||||
dpoName: data.dpo_name || '', dpoEmail: data.dpo_email || '',
|
||||
isComplete: data.is_complete || false,
|
||||
processingSystems: data.processing_systems || [], aiSystems: data.ai_systems || [],
|
||||
technicalContacts: data.technical_contacts || [],
|
||||
existingCertifications: data.existing_certifications || [],
|
||||
targetCertifications: data.target_certifications || [],
|
||||
targetCertificationOther: data.target_certification_other || '',
|
||||
reviewCycleMonths: data.review_cycle_months || 12,
|
||||
repos: data.repos || [], documentSources: data.document_sources || [],
|
||||
} as any
|
||||
setFormData(backendProfile)
|
||||
setCompanyProfile(backendProfile as CompanyProfile)
|
||||
if (backendProfile.isComplete) {
|
||||
setCurrentStep(99)
|
||||
dispatch({ type: 'COMPLETE_STEP', payload: 'company-profile' })
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch { /* Backend not available, fall through to SDK state */ }
|
||||
|
||||
if (!cancelled && state.companyProfile) {
|
||||
setFormData(state.companyProfile)
|
||||
if (state.companyProfile.isComplete) setCurrentStep(99)
|
||||
}
|
||||
}
|
||||
loadFromBackend()
|
||||
return () => { cancelled = true }
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [projectId])
|
||||
|
||||
const updateFormData = (updates: Partial<CompanyProfile>) => {
|
||||
setFormData(prev => ({ ...prev, ...updates }))
|
||||
}
|
||||
|
||||
// Auto-save to SDK context (debounced)
|
||||
const autoSaveRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const initialLoadDone = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialLoadDone.current) {
|
||||
if (formData.companyName !== undefined) initialLoadDone.current = true
|
||||
return
|
||||
}
|
||||
if (currentStep === 99) return
|
||||
if (autoSaveRef.current) clearTimeout(autoSaveRef.current)
|
||||
autoSaveRef.current = setTimeout(() => {
|
||||
const hasData = formData.companyName || (formData.industry && formData.industry.length > 0)
|
||||
if (hasData) setCompanyProfile({ ...formData, isComplete: false, completedAt: null } as CompanyProfile)
|
||||
}, 500)
|
||||
return () => { if (autoSaveRef.current) clearTimeout(autoSaveRef.current) }
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [formData, currentStep])
|
||||
|
||||
// Auto-save draft to backend (debounced, 2s)
|
||||
const backendAutoSaveRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const draftSaveTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialLoadDone.current) return
|
||||
if (currentStep === 99) return
|
||||
const hasData = formData.companyName || (formData.industry && formData.industry.length > 0)
|
||||
if (!hasData) return
|
||||
|
||||
if (backendAutoSaveRef.current) clearTimeout(backendAutoSaveRef.current)
|
||||
backendAutoSaveRef.current = setTimeout(async () => {
|
||||
try {
|
||||
await fetch(profileApiUrl(), {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(buildProfilePayload(formData, projectId, false)),
|
||||
})
|
||||
setDraftSaveStatus('saved')
|
||||
if (draftSaveTimerRef.current) clearTimeout(draftSaveTimerRef.current)
|
||||
draftSaveTimerRef.current = setTimeout(() => setDraftSaveStatus('idle'), 3000)
|
||||
} catch { /* Silent fail for auto-save */ }
|
||||
}, 2000)
|
||||
|
||||
return () => { if (backendAutoSaveRef.current) clearTimeout(backendAutoSaveRef.current) }
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [formData, currentStep])
|
||||
|
||||
const saveProfileDraft = async () => {
|
||||
setDraftSaveStatus('saving')
|
||||
try {
|
||||
await fetch(profileApiUrl(), {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(buildProfilePayload(formData, projectId, false)),
|
||||
})
|
||||
setCompanyProfile({ ...formData, isComplete: false, completedAt: null } as CompanyProfile)
|
||||
setDraftSaveStatus('saved')
|
||||
if (draftSaveTimerRef.current) clearTimeout(draftSaveTimerRef.current)
|
||||
draftSaveTimerRef.current = setTimeout(() => setDraftSaveStatus('idle'), 3000)
|
||||
} catch (err) {
|
||||
console.error('Draft save failed:', err)
|
||||
setDraftSaveStatus('error')
|
||||
if (draftSaveTimerRef.current) clearTimeout(draftSaveTimerRef.current)
|
||||
draftSaveTimerRef.current = setTimeout(() => setDraftSaveStatus('idle'), 5000)
|
||||
}
|
||||
}
|
||||
|
||||
const completeAndSaveProfile = async () => {
|
||||
if (autoSaveRef.current) clearTimeout(autoSaveRef.current)
|
||||
if (backendAutoSaveRef.current) clearTimeout(backendAutoSaveRef.current)
|
||||
|
||||
const completeProfile: CompanyProfile = { ...formData, isComplete: true, completedAt: new Date() } as CompanyProfile
|
||||
try {
|
||||
await fetch(profileApiUrl(), {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(buildProfilePayload(formData, projectId, true)),
|
||||
})
|
||||
} catch (err) { console.error('Failed to save company profile to backend:', err) }
|
||||
|
||||
setCompanyProfile(completeProfile)
|
||||
dispatch({ type: 'COMPLETE_STEP', payload: 'company-profile' })
|
||||
dispatch({ type: 'SET_STATE', payload: { projectVersion: (state.projectVersion || 0) + 1 } })
|
||||
setCurrentStep(99)
|
||||
}
|
||||
|
||||
const handleNext = () => {
|
||||
if (currentStep < lastStep) {
|
||||
const nextStep = currentStep + 1
|
||||
if (nextStep === 7 && !showMachineBuilderStep) {
|
||||
completeAndSaveProfile()
|
||||
return
|
||||
}
|
||||
saveProfileDraft()
|
||||
setCurrentStep(nextStep)
|
||||
} else {
|
||||
completeAndSaveProfile()
|
||||
}
|
||||
}
|
||||
|
||||
const handleBack = () => {
|
||||
if (currentStep > 1) { saveProfileDraft(); setCurrentStep(prev => prev - 1) }
|
||||
}
|
||||
|
||||
const handleDeleteProfile = async () => {
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
const response = await fetch(profileApiUrl(), { method: 'DELETE' })
|
||||
if (response.ok) {
|
||||
setFormData(INITIAL_FORM_DATA)
|
||||
setCurrentStep(1)
|
||||
dispatch({ type: 'SET_STATE', payload: { companyProfile: undefined } })
|
||||
}
|
||||
} catch (err) { console.error('Failed to delete company profile:', err) }
|
||||
finally { setIsDeleting(false); setShowDeleteConfirm(false) }
|
||||
}
|
||||
|
||||
const canProceed = () => {
|
||||
switch (currentStep) {
|
||||
case 1: return formData.companyName && formData.legalForm
|
||||
case 2: return formData.businessModel && (formData.offerings?.length || 0) > 0
|
||||
case 3: return formData.companySize
|
||||
case 4: return formData.headquartersCountry && (formData.targetMarkets?.length || 0) > 0
|
||||
case 5: return true
|
||||
case 6: return true
|
||||
case 7: return (formData.machineBuilder?.productDescription?.length || 0) > 0
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
const isLastStep = currentStep === lastStep || (currentStep === 6 && !showMachineBuilderStep)
|
||||
|
||||
return {
|
||||
formData, updateFormData, currentStep, setCurrentStep,
|
||||
wizardSteps, showMachineBuilderStep, isLastStep,
|
||||
draftSaveStatus, canProceed, handleNext, handleBack,
|
||||
handleDeleteProfile, showDeleteConfirm, setShowDeleteConfirm,
|
||||
isDeleting, goToNextStep,
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,73 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
export function ApiGdprProcessEditor({
|
||||
process,
|
||||
saving,
|
||||
onSave,
|
||||
}: {
|
||||
process: { id: string; process_key: string; title: string; description: string; legal_basis: string; retention_days: number; is_active: boolean }
|
||||
saving: boolean
|
||||
onSave: (title: string, description: string) => void
|
||||
}) {
|
||||
const [title, setTitle] = useState(process.title)
|
||||
const [description, setDescription] = useState(process.description || '')
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="border border-slate-200 rounded-xl bg-white overflow-hidden">
|
||||
<div className="p-4 flex items-start justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 bg-purple-100 text-purple-700 rounded-lg flex items-center justify-center flex-shrink-0 font-mono text-xs font-bold">
|
||||
{process.legal_basis?.replace('Art. ', '').replace(' DSGVO', '') || '?'}
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-slate-900">{title}</h4>
|
||||
<p className="text-sm text-slate-500">{description || 'Keine Beschreibung'}</p>
|
||||
{process.retention_days && (
|
||||
<span className="text-xs text-slate-400">Aufbewahrung: {process.retention_days} Tage</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="px-3 py-1.5 text-sm text-slate-600 hover:text-slate-900 border border-slate-300 rounded-lg hover:border-slate-400 flex-shrink-0"
|
||||
>
|
||||
{expanded ? 'Schliessen' : 'Bearbeiten'}
|
||||
</button>
|
||||
</div>
|
||||
{expanded && (
|
||||
<div className="border-t border-slate-200 p-4 space-y-3 bg-slate-50">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-700 mb-1">Titel</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-700 mb-1">Beschreibung</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={() => onSave(title, description)}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 text-sm text-white bg-purple-600 hover:bg-purple-700 rounded-lg font-medium disabled:opacity-60"
|
||||
>
|
||||
{saving ? 'Speichern...' : 'Speichern'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
export function ApiTemplateEditor({
|
||||
template,
|
||||
saving,
|
||||
onSave,
|
||||
onPreview,
|
||||
}: {
|
||||
template: { id: string; template_key: string; subject: string; body: string; language: string; is_active: boolean }
|
||||
saving: boolean
|
||||
onSave: (subject: string, body: string) => void
|
||||
onPreview: (subject: string, body: string) => void
|
||||
}) {
|
||||
const [subject, setSubject] = useState(template.subject)
|
||||
const [body, setBody] = useState(template.body)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="border border-slate-200 rounded-lg bg-white overflow-hidden">
|
||||
<div className="p-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`w-2.5 h-2.5 rounded-full ${template.is_active ? 'bg-green-400' : 'bg-slate-300'}`} />
|
||||
<div>
|
||||
<span className="font-medium text-slate-900 font-mono text-sm">{template.template_key}</span>
|
||||
<p className="text-sm text-slate-500 truncate max-w-xs">{subject}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 rounded text-xs uppercase">{template.language}</span>
|
||||
<button
|
||||
onClick={() => onPreview(subject, body)}
|
||||
className="px-3 py-1.5 text-sm text-slate-600 hover:text-slate-900 border border-slate-300 rounded-lg hover:border-slate-400"
|
||||
>
|
||||
Vorschau
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="px-3 py-1.5 text-sm text-slate-600 hover:text-slate-900 border border-slate-300 rounded-lg hover:border-slate-400"
|
||||
>
|
||||
{expanded ? 'Schliessen' : 'Bearbeiten'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{expanded && (
|
||||
<div className="border-t border-slate-200 p-4 space-y-3 bg-slate-50">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-700 mb-1">Betreff</label>
|
||||
<input
|
||||
type="text"
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-700 mb-1">Inhalt</label>
|
||||
<textarea
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
rows={8}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg p-3 border border-slate-200">
|
||||
<div className="text-xs font-medium text-slate-500 mb-1">Verfuegbare Platzhalter:</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{['{{name}}', '{{email}}', '{{date}}', '{{deadline}}', '{{company}}'].map(v => (
|
||||
<span key={v} className="px-2 py-0.5 bg-purple-100 text-purple-700 rounded text-xs font-mono">{v}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={() => onSave(subject, body)}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 text-sm text-white bg-purple-600 hover:bg-purple-700 rounded-lg font-medium disabled:opacity-60"
|
||||
>
|
||||
{saving ? 'Speichern...' : 'Speichern'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
export function ConsentTemplateCreateModal({
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: {
|
||||
onClose: () => void
|
||||
onSuccess: () => void
|
||||
}) {
|
||||
const [templateKey, setTemplateKey] = useState('')
|
||||
const [subject, setSubject] = useState('')
|
||||
const [body, setBody] = useState('')
|
||||
const [language, setLanguage] = useState('de')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function handleSave() {
|
||||
if (!templateKey.trim()) {
|
||||
setError('Template-Key ist erforderlich.')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch('/api/sdk/v1/consent-templates', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
template_key: templateKey.trim(),
|
||||
subject: subject.trim(),
|
||||
body: body.trim(),
|
||||
language,
|
||||
}),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
throw new Error(data.detail || data.message || `Fehler: ${res.status}`)
|
||||
}
|
||||
onSuccess()
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : 'Unbekannter Fehler')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-xl shadow-2xl w-full max-w-lg max-h-[90vh] overflow-y-auto">
|
||||
<div className="px-6 py-4 border-b border-slate-200 flex items-center justify-between">
|
||||
<h3 className="font-semibold text-slate-900">Neue E-Mail Vorlage</h3>
|
||||
<button onClick={onClose} className="text-slate-400 hover:text-slate-600">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">{error}</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Template-Key <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={templateKey}
|
||||
onChange={e => setTemplateKey(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
placeholder="z.B. dsr_confirmation"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Sprache</label>
|
||||
<select
|
||||
value={language}
|
||||
onChange={e => setLanguage(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
>
|
||||
<option value="de">Deutsch</option>
|
||||
<option value="en">English</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Betreff</label>
|
||||
<input
|
||||
type="text"
|
||||
value={subject}
|
||||
onChange={e => setSubject(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
placeholder="E-Mail Betreff"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Inhalt</label>
|
||||
<textarea
|
||||
value={body}
|
||||
onChange={e => setBody(e.target.value)}
|
||||
rows={6}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500 resize-none"
|
||||
placeholder="E-Mail Inhalt..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t border-slate-200 flex items-center justify-end gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm text-slate-600 hover:bg-slate-100 rounded-lg transition-colors"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Speichern...' : 'Vorlage erstellen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
'use client'
|
||||
|
||||
import type { Document, Tab } from '../_types'
|
||||
|
||||
export function DocumentsTab({
|
||||
loading,
|
||||
documents,
|
||||
setSelectedDocument,
|
||||
setActiveTab,
|
||||
}: {
|
||||
loading: boolean
|
||||
documents: Document[]
|
||||
setSelectedDocument: (id: string) => void
|
||||
setActiveTab: (t: Tab) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-lg font-semibold text-slate-900">Dokumente verwalten</h2>
|
||||
<button className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors text-sm font-medium">
|
||||
+ Neues Dokument
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-slate-500">Lade Dokumente...</div>
|
||||
) : documents.length === 0 ? (
|
||||
<div className="text-center py-12 text-slate-500">
|
||||
Keine Dokumente vorhanden
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200">
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-slate-500">Typ</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-slate-500">Name</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-slate-500">Beschreibung</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-slate-500">Pflicht</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-slate-500">Erstellt</th>
|
||||
<th className="text-right py-3 px-4 text-sm font-medium text-slate-500">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{documents.map((doc) => (
|
||||
<tr key={doc.id} className="border-b border-slate-100 hover:bg-slate-50">
|
||||
<td className="py-3 px-4">
|
||||
<span className="px-2 py-1 bg-slate-100 text-slate-700 rounded text-xs font-medium">
|
||||
{doc.type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4 font-medium text-slate-900">{doc.name}</td>
|
||||
<td className="py-3 px-4 text-slate-600 text-sm">{doc.description}</td>
|
||||
<td className="py-3 px-4">
|
||||
{doc.mandatory ? (
|
||||
<span className="text-green-600">Ja</span>
|
||||
) : (
|
||||
<span className="text-slate-400">Nein</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm text-slate-500">
|
||||
{new Date(doc.created_at).toLocaleDateString('de-DE')}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right">
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedDocument(doc.id)
|
||||
setActiveTab('versions')
|
||||
}}
|
||||
className="text-purple-600 hover:text-purple-700 text-sm font-medium mr-3"
|
||||
>
|
||||
Versionen
|
||||
</button>
|
||||
<button className="text-slate-500 hover:text-slate-700 text-sm">
|
||||
Bearbeiten
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
'use client'
|
||||
|
||||
import type { EmailTemplateData } from '../_types'
|
||||
|
||||
export function EmailTemplateEditModal({
|
||||
editingTemplate,
|
||||
onChange,
|
||||
onClose,
|
||||
onSave,
|
||||
}: {
|
||||
editingTemplate: EmailTemplateData
|
||||
onChange: (tpl: EmailTemplateData) => void
|
||||
onClose: () => void
|
||||
onSave: (tpl: EmailTemplateData) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<div className="px-6 py-4 border-b border-slate-200 flex items-center justify-between">
|
||||
<h3 className="font-semibold text-slate-900">E-Mail Vorlage bearbeiten</h3>
|
||||
<button onClick={onClose} className="text-slate-400 hover:text-slate-600">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Betreff</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editingTemplate.subject}
|
||||
onChange={(e) => onChange({ ...editingTemplate, subject: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Inhalt</label>
|
||||
<textarea
|
||||
value={editingTemplate.body}
|
||||
onChange={(e) => onChange({ ...editingTemplate, body: e.target.value })}
|
||||
rows={12}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-lg p-3">
|
||||
<div className="text-xs font-medium text-slate-500 mb-1">Verfuegbare Platzhalter:</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{['{{name}}', '{{email}}', '{{referenceNumber}}', '{{date}}', '{{deadline}}', '{{company}}'].map(v => (
|
||||
<span key={v} className="px-2 py-0.5 bg-purple-100 text-purple-700 rounded text-xs font-mono">{v}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t border-slate-200 flex justify-end gap-3">
|
||||
<button onClick={onClose} className="px-4 py-2 text-sm text-slate-600 hover:bg-slate-100 rounded-lg">
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onSave(editingTemplate)}
|
||||
className="px-4 py-2 text-sm text-white bg-purple-600 hover:bg-purple-700 rounded-lg font-medium"
|
||||
>
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function EmailTemplatePreviewModal({
|
||||
previewTemplate,
|
||||
onClose,
|
||||
}: {
|
||||
previewTemplate: EmailTemplateData
|
||||
onClose: () => void
|
||||
}) {
|
||||
const substitute = (text: string) =>
|
||||
text
|
||||
.replace(/\{\{name\}\}/g, 'Max Mustermann')
|
||||
.replace(/\{\{email\}\}/g, 'max@example.de')
|
||||
.replace(/\{\{referenceNumber\}\}/g, 'DSR-2025-000001')
|
||||
.replace(/\{\{date\}\}/g, new Date().toLocaleDateString('de-DE'))
|
||||
.replace(/\{\{deadline\}\}/g, '30 Tage')
|
||||
.replace(/\{\{company\}\}/g, 'BreakPilot GmbH')
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<div className="px-6 py-4 border-b border-slate-200 flex items-center justify-between">
|
||||
<h3 className="font-semibold text-slate-900">Vorschau</h3>
|
||||
<button onClick={onClose} className="text-slate-400 hover:text-slate-600">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<div className="text-xs text-slate-500 mb-1">Betreff:</div>
|
||||
<div className="font-medium text-slate-900">
|
||||
{substitute(previewTemplate.subject)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border border-slate-200 rounded-lg p-4 whitespace-pre-wrap text-sm text-slate-700">
|
||||
{substitute(previewTemplate.body)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t border-slate-200 flex justify-end">
|
||||
<button onClick={onClose} className="px-4 py-2 text-sm text-slate-600 hover:bg-slate-100 rounded-lg">
|
||||
Schliessen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
'use client'
|
||||
|
||||
import type { ApiEmailTemplate, EmailTemplateData } from '../_types'
|
||||
import { emailTemplates, emailCategories } from '../_data'
|
||||
import { ApiTemplateEditor } from './ApiTemplateEditor'
|
||||
|
||||
export function EmailsTab({
|
||||
apiEmailTemplates,
|
||||
templatesLoading,
|
||||
savingTemplateId,
|
||||
savedTemplates,
|
||||
setShowCreateTemplateModal,
|
||||
saveApiEmailTemplate,
|
||||
setPreviewTemplate,
|
||||
setEditingTemplate,
|
||||
}: {
|
||||
apiEmailTemplates: ApiEmailTemplate[]
|
||||
templatesLoading: boolean
|
||||
savingTemplateId: string | null
|
||||
savedTemplates: Record<string, EmailTemplateData>
|
||||
setShowCreateTemplateModal: (v: boolean) => void
|
||||
saveApiEmailTemplate: (t: { id: string; subject: string; body: string }) => void
|
||||
setPreviewTemplate: (t: EmailTemplateData | null) => void
|
||||
setEditingTemplate: (t: EmailTemplateData | null) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-900">E-Mail Vorlagen</h2>
|
||||
<p className="text-sm text-slate-500 mt-1">
|
||||
{apiEmailTemplates.length > 0
|
||||
? `${apiEmailTemplates.length} DSGVO-Vorlagen aus der Datenbank`
|
||||
: '16 Lifecycle-Vorlagen fuer automatisierte Kommunikation'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreateTemplateModal(true)}
|
||||
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors text-sm font-medium"
|
||||
>
|
||||
+ Neue Vorlage
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* API-backed templates section */}
|
||||
{templatesLoading ? (
|
||||
<div className="text-center py-8 text-slate-500">Lade Vorlagen aus der Datenbank...</div>
|
||||
) : apiEmailTemplates.length > 0 ? (
|
||||
<div className="space-y-4 mb-8">
|
||||
<h3 className="text-sm font-semibold text-slate-700 uppercase tracking-wider mb-3">DSGVO-Pflichtvorlagen</h3>
|
||||
{apiEmailTemplates.map((template) => (
|
||||
<ApiTemplateEditor
|
||||
key={template.id}
|
||||
template={template}
|
||||
saving={savingTemplateId === template.id}
|
||||
onSave={(subject, body) => saveApiEmailTemplate({ id: template.id, subject, body })}
|
||||
onPreview={(subject, body) => setPreviewTemplate({ key: template.template_key, subject, body })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Category Filter for static templates */}
|
||||
{apiEmailTemplates.length === 0 && (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-2 mb-6">
|
||||
<span className="text-sm text-slate-500 py-1">Filter:</span>
|
||||
{emailCategories.map((cat) => (
|
||||
<span key={cat.key} className={`px-3 py-1 rounded-full text-xs font-medium ${cat.color}`}>
|
||||
{cat.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Templates grouped by category (fallback when no API data) */}
|
||||
{emailCategories.map((category) => (
|
||||
<div key={category.key} className="mb-8">
|
||||
<h3 className="text-sm font-semibold text-slate-700 uppercase tracking-wider mb-3 flex items-center gap-2">
|
||||
<span className={`w-2 h-2 rounded-full ${category.color.split(' ')[0]}`}></span>
|
||||
{category.label}
|
||||
</h3>
|
||||
<div className="grid gap-3">
|
||||
{emailTemplates
|
||||
.filter((t) => t.category === category.key)
|
||||
.map((template) => (
|
||||
<div
|
||||
key={template.key}
|
||||
className="border border-slate-200 rounded-lg p-4 flex items-center justify-between hover:border-purple-300 transition-colors bg-white"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`w-10 h-10 rounded-lg ${category.color} flex items-center justify-center text-lg`}>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-slate-900">{template.name}</h4>
|
||||
<p className="text-sm text-slate-500">{template.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="px-2 py-1 bg-green-100 text-green-700 rounded text-xs">
|
||||
{savedTemplates[template.key] ? 'Angepasst' : 'Aktiv'}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
const existing = savedTemplates[template.key]
|
||||
setEditingTemplate({
|
||||
key: template.key,
|
||||
subject: existing?.subject || `Betreff: ${template.name}`,
|
||||
body: existing?.body || `Sehr geehrte(r) {{name}},\n\n${template.description}\n\nMit freundlichen Gruessen\nIhr Datenschutz-Team`,
|
||||
})
|
||||
}}
|
||||
className="px-3 py-1.5 text-sm text-slate-600 hover:text-slate-900 border border-slate-300 rounded-lg hover:border-slate-400"
|
||||
>
|
||||
Bearbeiten
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const existing = savedTemplates[template.key]
|
||||
setPreviewTemplate({
|
||||
key: template.key,
|
||||
subject: existing?.subject || `Betreff: ${template.name}`,
|
||||
body: existing?.body || `Sehr geehrte(r) {{name}},\n\n${template.description}\n\nMit freundlichen Gruessen\nIhr Datenschutz-Team`,
|
||||
})
|
||||
}}
|
||||
className="px-3 py-1.5 text-sm text-slate-600 hover:text-slate-900 border border-slate-300 rounded-lg hover:border-slate-400"
|
||||
>
|
||||
Vorschau
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import type { ApiGdprProcess, DsrOverview } from '../_types'
|
||||
import { gdprProcesses } from '../_data'
|
||||
import { ApiGdprProcessEditor } from './ApiGdprProcessEditor'
|
||||
|
||||
export function GdprTab({
|
||||
router,
|
||||
apiGdprProcesses,
|
||||
gdprLoading,
|
||||
savingProcessId,
|
||||
saveApiGdprProcess,
|
||||
dsrCounts,
|
||||
dsrOverview,
|
||||
}: {
|
||||
router: { push: (path: string) => void }
|
||||
apiGdprProcesses: ApiGdprProcess[]
|
||||
gdprLoading: boolean
|
||||
savingProcessId: string | null
|
||||
saveApiGdprProcess: (p: { id: string; title: string; description: string }) => void
|
||||
dsrCounts: Record<string, number>
|
||||
dsrOverview: DsrOverview
|
||||
}) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-900">DSGVO Betroffenenrechte</h2>
|
||||
<p className="text-sm text-slate-500 mt-1">Artikel 15-21 Prozesse und Vorlagen</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => router.push('/sdk/dsr')}
|
||||
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors text-sm font-medium"
|
||||
>
|
||||
+ DSR Anfrage erstellen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Info Banner */}
|
||||
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4 mb-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-2xl">*</span>
|
||||
<div>
|
||||
<h4 className="font-medium text-purple-900">Data Subject Rights (DSR)</h4>
|
||||
<p className="text-sm text-purple-700 mt-1">
|
||||
Hier verwalten Sie alle DSGVO-Anfragen. Jeder Artikel hat definierte Prozesse, SLAs und automatisierte Workflows.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* API-backed GDPR Processes */}
|
||||
{gdprLoading ? (
|
||||
<div className="text-center py-8 text-slate-500">Lade DSGVO-Prozesse...</div>
|
||||
) : apiGdprProcesses.length > 0 ? (
|
||||
<div className="space-y-4 mb-8">
|
||||
<h3 className="text-sm font-semibold text-slate-700 uppercase tracking-wider mb-3">Konfigurierte Prozesse</h3>
|
||||
{apiGdprProcesses.map((process) => (
|
||||
<ApiGdprProcessEditor
|
||||
key={process.id}
|
||||
process={process}
|
||||
saving={savingProcessId === process.id}
|
||||
onSave={(title, description) => saveApiGdprProcess({ id: process.id, title, description })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Static GDPR Process Cards (always shown as reference) */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold text-slate-700 uppercase tracking-wider mb-3">DSGVO Artikel-Uebersicht</h3>
|
||||
{gdprProcesses.map((process) => (
|
||||
<div
|
||||
key={process.article}
|
||||
className="border border-slate-200 rounded-xl p-5 hover:border-purple-300 transition-colors bg-white"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-12 h-12 bg-purple-100 text-purple-700 rounded-lg flex items-center justify-center font-bold text-lg">
|
||||
{process.article}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-semibold text-slate-900">{process.title}</h3>
|
||||
<span className="px-2 py-0.5 bg-green-100 text-green-700 rounded text-xs">Aktiv</span>
|
||||
</div>
|
||||
<p className="text-sm text-slate-600 mb-3">{process.description}</p>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
{process.actions.map((action, idx) => (
|
||||
<span key={idx} className="px-2 py-1 bg-slate-100 text-slate-600 rounded text-xs">
|
||||
{action}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* SLA */}
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<span className="text-slate-500">
|
||||
SLA: <span className="font-medium text-slate-700">{process.sla}</span>
|
||||
</span>
|
||||
<span className="text-slate-300">|</span>
|
||||
<span className="text-slate-500">
|
||||
Offene Anfragen: <span className={`font-medium ${(dsrCounts[process.article] || 0) > 0 ? 'text-orange-600' : 'text-slate-700'}`}>{dsrCounts[process.article] || 0}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Link
|
||||
href={`/sdk/dsr?type=${process.article === '15' ? 'access' : process.article === '16' ? 'rectification' : process.article === '17' ? 'erasure' : process.article === '18' ? 'restriction' : process.article === '20' ? 'portability' : 'objection'}`}
|
||||
className="px-3 py-1.5 text-sm text-white bg-purple-600 hover:bg-purple-700 rounded-lg text-center"
|
||||
>
|
||||
Anfragen
|
||||
</Link>
|
||||
<button className="px-3 py-1.5 text-sm text-slate-600 hover:text-slate-900 border border-slate-300 rounded-lg hover:border-slate-400">
|
||||
Vorlage
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* DSR Request Statistics */}
|
||||
<div className="mt-8 pt-6 border-t border-slate-200">
|
||||
<h3 className="text-sm font-semibold text-slate-700 uppercase tracking-wider mb-4">DSR Uebersicht</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="bg-slate-50 rounded-lg p-4 text-center">
|
||||
<div className={`text-2xl font-bold ${dsrOverview.open > 0 ? 'text-blue-600' : 'text-slate-900'}`}>{dsrOverview.open}</div>
|
||||
<div className="text-xs text-slate-500 mt-1">Offen</div>
|
||||
</div>
|
||||
<div className="bg-green-50 rounded-lg p-4 text-center">
|
||||
<div className="text-2xl font-bold text-green-700">{dsrOverview.completed}</div>
|
||||
<div className="text-xs text-slate-500 mt-1">Erledigt</div>
|
||||
</div>
|
||||
<div className="bg-yellow-50 rounded-lg p-4 text-center">
|
||||
<div className="text-2xl font-bold text-yellow-700">{dsrOverview.in_progress}</div>
|
||||
<div className="text-xs text-slate-500 mt-1">In Bearbeitung</div>
|
||||
</div>
|
||||
<div className="bg-red-50 rounded-lg p-4 text-center">
|
||||
<div className={`text-2xl font-bold ${dsrOverview.overdue > 0 ? 'text-red-700' : 'text-slate-400'}`}>{dsrOverview.overdue}</div>
|
||||
<div className="text-xs text-slate-500 mt-1">Ueberfaellig</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
'use client'
|
||||
|
||||
import type { ConsentStats } from '../_types'
|
||||
|
||||
export function StatsTab({ consentStats }: { consentStats: ConsentStats }) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h2 className="text-lg font-semibold text-slate-900 mb-6">Statistiken</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
<div className="bg-slate-50 rounded-xl p-6">
|
||||
<div className="text-3xl font-bold text-slate-900">{consentStats.activeConsents}</div>
|
||||
<div className="text-sm text-slate-500 mt-1">Aktive Zustimmungen</div>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-xl p-6">
|
||||
<div className="text-3xl font-bold text-slate-900">{consentStats.documentCount}</div>
|
||||
<div className="text-sm text-slate-500 mt-1">Dokumente</div>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-xl p-6">
|
||||
<div className={`text-3xl font-bold ${consentStats.openDSRs > 0 ? 'text-orange-600' : 'text-slate-900'}`}>
|
||||
{consentStats.openDSRs}
|
||||
</div>
|
||||
<div className="text-sm text-slate-500 mt-1">Offene DSR-Anfragen</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-slate-200 rounded-lg p-6">
|
||||
<h3 className="font-semibold text-slate-900 mb-4">Zustimmungsrate nach Dokument</h3>
|
||||
<div className="text-center py-8 text-slate-400 text-sm">
|
||||
Diagramm wird in einer zukuenftigen Version verfuegbar sein
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
'use client'
|
||||
|
||||
import type { Document, Version } from '../_types'
|
||||
|
||||
export function VersionsTab({
|
||||
loading,
|
||||
documents,
|
||||
versions,
|
||||
selectedDocument,
|
||||
setSelectedDocument,
|
||||
}: {
|
||||
loading: boolean
|
||||
documents: Document[]
|
||||
versions: Version[]
|
||||
selectedDocument: string
|
||||
setSelectedDocument: (id: string) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<h2 className="text-lg font-semibold text-slate-900">Versionen</h2>
|
||||
<select
|
||||
value={selectedDocument}
|
||||
onChange={(e) => setSelectedDocument(e.target.value)}
|
||||
className="px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||||
>
|
||||
<option value="">Dokument auswaehlen...</option>
|
||||
{documents.map((doc) => (
|
||||
<option key={doc.id} value={doc.id}>
|
||||
{doc.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{selectedDocument && (
|
||||
<button className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors text-sm font-medium">
|
||||
+ Neue Version
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!selectedDocument ? (
|
||||
<div className="text-center py-12 text-slate-500">
|
||||
Bitte waehlen Sie ein Dokument aus
|
||||
</div>
|
||||
) : loading ? (
|
||||
<div className="text-center py-12 text-slate-500">Lade Versionen...</div>
|
||||
) : versions.length === 0 ? (
|
||||
<div className="text-center py-12 text-slate-500">
|
||||
Keine Versionen vorhanden
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{versions.map((version) => (
|
||||
<div
|
||||
key={version.id}
|
||||
className="border border-slate-200 rounded-lg p-4 hover:border-purple-300 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-semibold text-slate-900">v{version.version}</span>
|
||||
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 rounded text-xs">
|
||||
{version.language.toUpperCase()}
|
||||
</span>
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded text-xs ${
|
||||
version.status === 'published'
|
||||
? 'bg-green-100 text-green-700'
|
||||
: version.status === 'draft'
|
||||
? 'bg-yellow-100 text-yellow-700'
|
||||
: 'bg-slate-100 text-slate-600'
|
||||
}`}
|
||||
>
|
||||
{version.status}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-slate-700">{version.title}</h3>
|
||||
<p className="text-sm text-slate-500 mt-1">
|
||||
Erstellt: {new Date(version.created_at).toLocaleDateString('de-DE')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button className="px-3 py-1.5 text-sm text-slate-600 hover:text-slate-900 border border-slate-300 rounded-lg hover:border-slate-400">
|
||||
Bearbeiten
|
||||
</button>
|
||||
{version.status === 'draft' && (
|
||||
<button className="px-3 py-1.5 text-sm text-white bg-green-600 hover:bg-green-700 rounded-lg">
|
||||
Veroeffentlichen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
80
admin-compliance/app/sdk/consent-management/_data.ts
Normal file
80
admin-compliance/app/sdk/consent-management/_data.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
// 16 Lifecycle Email Templates
|
||||
export const emailTemplates = [
|
||||
{ name: 'Willkommens-E-Mail', key: 'welcome', category: 'onboarding', description: 'Wird bei Registrierung versendet' },
|
||||
{ name: 'E-Mail Bestaetigung', key: 'email_verification', category: 'onboarding', description: 'Bestaetigungslink fuer E-Mail-Adresse' },
|
||||
{ name: 'Konto aktiviert', key: 'account_activated', category: 'onboarding', description: 'Bestaetigung der Kontoaktivierung' },
|
||||
{ name: 'Passwort zuruecksetzen', key: 'password_reset', category: 'security', description: 'Link zum Zuruecksetzen des Passworts' },
|
||||
{ name: 'Passwort geaendert', key: 'password_changed', category: 'security', description: 'Bestaetigung der Passwortaenderung' },
|
||||
{ name: 'Neue Anmeldung', key: 'new_login', category: 'security', description: 'Benachrichtigung ueber Anmeldung von neuem Geraet' },
|
||||
{ name: '2FA aktiviert', key: '2fa_enabled', category: 'security', description: 'Bestaetigung der 2FA-Aktivierung' },
|
||||
{ name: 'Neue Dokumentversion', key: 'new_document_version', category: 'consent', description: 'Info ueber neue Dokumentversion zur Zustimmung' },
|
||||
{ name: 'Zustimmung erteilt', key: 'consent_given', category: 'consent', description: 'Bestaetigung der erteilten Zustimmung' },
|
||||
{ name: 'Zustimmung widerrufen', key: 'consent_withdrawn', category: 'consent', description: 'Bestaetigung des Widerrufs' },
|
||||
{ name: 'DSR Anfrage erhalten', key: 'dsr_received', category: 'gdpr', description: 'Bestaetigung des Eingangs einer DSGVO-Anfrage' },
|
||||
{ name: 'Datenexport bereit', key: 'export_ready', category: 'gdpr', description: 'Benachrichtigung ueber fertigen Datenexport' },
|
||||
{ name: 'Daten geloescht', key: 'data_deleted', category: 'gdpr', description: 'Bestaetigung der Datenloeschung' },
|
||||
{ name: 'Daten berichtigt', key: 'data_rectified', category: 'gdpr', description: 'Bestaetigung der Datenberichtigung' },
|
||||
{ name: 'Konto deaktiviert', key: 'account_deactivated', category: 'lifecycle', description: 'Konto wurde deaktiviert' },
|
||||
{ name: 'Konto geloescht', key: 'account_deleted', category: 'lifecycle', description: 'Bestaetigung der Kontoloeschung' },
|
||||
]
|
||||
|
||||
// GDPR Article 15-21 Processes
|
||||
export const gdprProcesses = [
|
||||
{
|
||||
article: '15',
|
||||
title: 'Auskunftsrecht',
|
||||
description: 'Recht auf Bestaetigung und Auskunft ueber verarbeitete personenbezogene Daten',
|
||||
actions: ['Datenexport generieren', 'Verarbeitungszwecke auflisten', 'Empfaenger auflisten'],
|
||||
sla: '30 Tage',
|
||||
},
|
||||
{
|
||||
article: '16',
|
||||
title: 'Recht auf Berichtigung',
|
||||
description: 'Recht auf Berichtigung unrichtiger personenbezogener Daten',
|
||||
actions: ['Daten bearbeiten', 'Aenderungshistorie fuehren', 'Benachrichtigung senden'],
|
||||
sla: '30 Tage',
|
||||
},
|
||||
{
|
||||
article: '17',
|
||||
title: 'Recht auf Loeschung ("Vergessenwerden")',
|
||||
description: 'Recht auf Loeschung personenbezogener Daten unter bestimmten Voraussetzungen',
|
||||
actions: ['Loeschantrag pruefen', 'Daten loeschen', 'Aufbewahrungsfristen pruefen', 'Loeschbestaetigung senden'],
|
||||
sla: '30 Tage',
|
||||
},
|
||||
{
|
||||
article: '18',
|
||||
title: 'Recht auf Einschraenkung der Verarbeitung',
|
||||
description: 'Recht auf Markierung von Daten zur eingeschraenkten Verarbeitung',
|
||||
actions: ['Daten markieren', 'Verarbeitung einschraenken', 'Benachrichtigung bei Aufhebung'],
|
||||
sla: '30 Tage',
|
||||
},
|
||||
{
|
||||
article: '19',
|
||||
title: 'Mitteilungspflicht',
|
||||
description: 'Pflicht zur Mitteilung von Berichtigung, Loeschung oder Einschraenkung an Empfaenger',
|
||||
actions: ['Empfaenger ermitteln', 'Mitteilungen versenden', 'Protokollierung'],
|
||||
sla: 'Unverzueglich',
|
||||
},
|
||||
{
|
||||
article: '20',
|
||||
title: 'Recht auf Datenuebertragbarkeit',
|
||||
description: 'Recht auf Erhalt der Daten in strukturiertem, maschinenlesbarem Format',
|
||||
actions: ['JSON/CSV Export', 'API-Schnittstelle', 'Direkte Uebertragung'],
|
||||
sla: '30 Tage',
|
||||
},
|
||||
{
|
||||
article: '21',
|
||||
title: 'Widerspruchsrecht',
|
||||
description: 'Recht auf Widerspruch gegen Verarbeitung aus berechtigtem Interesse oder Direktwerbung',
|
||||
actions: ['Widerspruch erfassen', 'Verarbeitung stoppen', 'Marketing-Opt-out'],
|
||||
sla: 'Unverzueglich',
|
||||
},
|
||||
]
|
||||
|
||||
export const emailCategories = [
|
||||
{ key: 'onboarding', label: 'Onboarding', color: 'bg-blue-100 text-blue-700' },
|
||||
{ key: 'security', label: 'Sicherheit', color: 'bg-red-100 text-red-700' },
|
||||
{ key: 'consent', label: 'Zustimmung', color: 'bg-green-100 text-green-700' },
|
||||
{ key: 'gdpr', label: 'DSGVO', color: 'bg-purple-100 text-purple-700' },
|
||||
{ key: 'lifecycle', label: 'Lifecycle', color: 'bg-orange-100 text-orange-700' },
|
||||
]
|
||||
@@ -0,0 +1,291 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { API_BASE } from '../_types'
|
||||
import type {
|
||||
Tab, Document, Version, ApiEmailTemplate, ApiGdprProcess,
|
||||
ConsentStats, DsrOverview, EmailTemplateData,
|
||||
} from '../_types'
|
||||
|
||||
export function useConsentData(activeTab: Tab, selectedDocument: string) {
|
||||
const [documents, setDocuments] = useState<Document[]>([])
|
||||
const [versions, setVersions] = useState<Version[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Stats state
|
||||
const [consentStats, setConsentStats] = useState<ConsentStats>({ activeConsents: 0, documentCount: 0, openDSRs: 0 })
|
||||
|
||||
// GDPR tab state
|
||||
const [dsrCounts, setDsrCounts] = useState<Record<string, number>>({})
|
||||
const [dsrOverview, setDsrOverview] = useState<DsrOverview>({ open: 0, completed: 0, in_progress: 0, overdue: 0 })
|
||||
|
||||
// Email template editor state
|
||||
const [savedTemplates, setSavedTemplates] = useState<Record<string, EmailTemplateData>>({})
|
||||
|
||||
// API-backed email templates and GDPR processes
|
||||
const [apiEmailTemplates, setApiEmailTemplates] = useState<ApiEmailTemplate[]>([])
|
||||
const [apiGdprProcesses, setApiGdprProcesses] = useState<ApiGdprProcess[]>([])
|
||||
const [templatesLoading, setTemplatesLoading] = useState(false)
|
||||
const [gdprLoading, setGdprLoading] = useState(false)
|
||||
const [savingTemplateId, setSavingTemplateId] = useState<string | null>(null)
|
||||
const [savingProcessId, setSavingProcessId] = useState<string | null>(null)
|
||||
|
||||
// Auth token (in production, get from auth context)
|
||||
const [authToken, setAuthToken] = useState<string>('')
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('bp_admin_token')
|
||||
if (token) {
|
||||
setAuthToken(token)
|
||||
}
|
||||
// Load saved email templates from localStorage
|
||||
try {
|
||||
const saved = localStorage.getItem('sdk-email-templates')
|
||||
if (saved) {
|
||||
setSavedTemplates(JSON.parse(saved))
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'documents') {
|
||||
loadDocuments()
|
||||
} else if (activeTab === 'versions' && selectedDocument) {
|
||||
loadVersions(selectedDocument)
|
||||
} else if (activeTab === 'stats') {
|
||||
loadStats()
|
||||
} else if (activeTab === 'gdpr') {
|
||||
loadGDPRData()
|
||||
loadApiGdprProcesses()
|
||||
} else if (activeTab === 'emails') {
|
||||
loadApiEmailTemplates()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeTab, selectedDocument, authToken])
|
||||
|
||||
async function loadApiEmailTemplates() {
|
||||
setTemplatesLoading(true)
|
||||
try {
|
||||
const res = await fetch('/api/sdk/v1/consent-templates')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setApiEmailTemplates(Array.isArray(data) ? data : [])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load email templates from API:', err)
|
||||
} finally {
|
||||
setTemplatesLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadApiGdprProcesses() {
|
||||
setGdprLoading(true)
|
||||
try {
|
||||
const res = await fetch('/api/sdk/v1/consent-templates/gdpr-processes')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setApiGdprProcesses(Array.isArray(data) ? data : [])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load GDPR processes from API:', err)
|
||||
} finally {
|
||||
setGdprLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function saveApiEmailTemplate(template: { id: string; subject: string; body: string }) {
|
||||
setSavingTemplateId(template.id)
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/consent-templates/${template.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ subject: template.subject, body: template.body }),
|
||||
})
|
||||
if (res.ok) {
|
||||
const updated = await res.json()
|
||||
setApiEmailTemplates(prev => prev.map(t => t.id === updated.id ? updated : t))
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to save email template:', err)
|
||||
} finally {
|
||||
setSavingTemplateId(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function saveApiGdprProcess(process: { id: string; title: string; description: string }) {
|
||||
setSavingProcessId(process.id)
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/consent-templates/gdpr-processes/${process.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title: process.title, description: process.description }),
|
||||
})
|
||||
if (res.ok) {
|
||||
const updated = await res.json()
|
||||
setApiGdprProcesses(prev => prev.map(p => p.id === updated.id ? updated : p))
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to save GDPR process:', err)
|
||||
} finally {
|
||||
setSavingProcessId(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDocuments() {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/documents`, {
|
||||
headers: authToken ? { 'Authorization': `Bearer ${authToken}` } : {}
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setDocuments(data.documents || [])
|
||||
} else {
|
||||
const errorData = await res.json().catch(() => ({}))
|
||||
setError(errorData.error || 'Fehler beim Laden der Dokumente')
|
||||
}
|
||||
} catch {
|
||||
setError('Verbindungsfehler zum Server')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadVersions(docId: string) {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/documents/${docId}/versions`, {
|
||||
headers: authToken ? { 'Authorization': `Bearer ${authToken}` } : {}
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setVersions(data.versions || [])
|
||||
} else {
|
||||
const errorData = await res.json().catch(() => ({}))
|
||||
setError(errorData.error || 'Fehler beim Laden der Versionen')
|
||||
}
|
||||
} catch {
|
||||
setError('Verbindungsfehler zum Server')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const token = localStorage.getItem('bp_admin_token')
|
||||
const [statsRes, docsRes] = await Promise.all([
|
||||
fetch(`${API_BASE}/stats`, {
|
||||
headers: token ? { 'Authorization': `Bearer ${token}` } : {}
|
||||
}),
|
||||
fetch(`${API_BASE}/documents`, {
|
||||
headers: token ? { 'Authorization': `Bearer ${token}` } : {}
|
||||
}),
|
||||
])
|
||||
|
||||
let activeConsents = 0
|
||||
let documentCount = 0
|
||||
let openDSRs = 0
|
||||
|
||||
if (statsRes.ok) {
|
||||
const statsData = await statsRes.json()
|
||||
activeConsents = statsData.total_consents || statsData.active_consents || 0
|
||||
}
|
||||
|
||||
if (docsRes.ok) {
|
||||
const docsData = await docsRes.json()
|
||||
documentCount = (docsData.documents || []).length
|
||||
}
|
||||
|
||||
// Try to get DSR count
|
||||
try {
|
||||
const dsrRes = await fetch('/api/sdk/v1/compliance/dsr', {
|
||||
headers: {
|
||||
'X-Tenant-ID': localStorage.getItem('bp_tenant_id') || '',
|
||||
'X-User-ID': localStorage.getItem('bp_user_id') || '',
|
||||
}
|
||||
})
|
||||
if (dsrRes.ok) {
|
||||
const dsrData = await dsrRes.json()
|
||||
const dsrs = dsrData.dsrs || []
|
||||
openDSRs = dsrs.filter((r: any) => r.status !== 'completed' && r.status !== 'rejected').length
|
||||
}
|
||||
} catch { /* DSR endpoint might not be available */ }
|
||||
|
||||
setConsentStats({ activeConsents, documentCount, openDSRs })
|
||||
} catch (err) {
|
||||
console.error('Failed to load stats:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadGDPRData() {
|
||||
try {
|
||||
const res = await fetch('/api/sdk/v1/compliance/dsr', {
|
||||
headers: {
|
||||
'X-Tenant-ID': localStorage.getItem('bp_tenant_id') || '',
|
||||
'X-User-ID': localStorage.getItem('bp_user_id') || '',
|
||||
}
|
||||
})
|
||||
if (!res.ok) return
|
||||
|
||||
const data = await res.json()
|
||||
const dsrs = data.dsrs || []
|
||||
const now = new Date()
|
||||
|
||||
// Count per article type
|
||||
const counts: Record<string, number> = {}
|
||||
const typeMapping: Record<string, string> = {
|
||||
'access': '15',
|
||||
'rectification': '16',
|
||||
'erasure': '17',
|
||||
'restriction': '18',
|
||||
'portability': '20',
|
||||
'objection': '21',
|
||||
}
|
||||
|
||||
for (const dsr of dsrs) {
|
||||
if (dsr.status === 'completed' || dsr.status === 'rejected') continue
|
||||
const article = typeMapping[dsr.request_type]
|
||||
if (article) {
|
||||
counts[article] = (counts[article] || 0) + 1
|
||||
}
|
||||
}
|
||||
setDsrCounts(counts)
|
||||
|
||||
// Calculate overview
|
||||
const open = dsrs.filter((r: any) => r.status === 'received' || r.status === 'verified').length
|
||||
const completed = dsrs.filter((r: any) => r.status === 'completed').length
|
||||
const in_progress = dsrs.filter((r: any) => r.status === 'in_progress').length
|
||||
const overdue = dsrs.filter((r: any) => {
|
||||
if (r.status === 'completed' || r.status === 'rejected') return false
|
||||
const deadline = r.extended_deadline_at ? new Date(r.extended_deadline_at) : new Date(r.deadline_at)
|
||||
return deadline < now
|
||||
}).length
|
||||
|
||||
setDsrOverview({ open, completed, in_progress, overdue })
|
||||
} catch (err) {
|
||||
console.error('Failed to load GDPR data:', err)
|
||||
}
|
||||
}
|
||||
|
||||
function saveEmailTemplate(template: EmailTemplateData) {
|
||||
const updated = { ...savedTemplates, [template.key]: template }
|
||||
setSavedTemplates(updated)
|
||||
localStorage.setItem('sdk-email-templates', JSON.stringify(updated))
|
||||
}
|
||||
|
||||
return {
|
||||
documents, versions, loading, error, setError,
|
||||
consentStats, dsrCounts, dsrOverview,
|
||||
savedTemplates, saveEmailTemplate,
|
||||
apiEmailTemplates, apiGdprProcesses,
|
||||
templatesLoading, gdprLoading,
|
||||
savingTemplateId, savingProcessId,
|
||||
saveApiEmailTemplate, saveApiGdprProcess,
|
||||
loadApiEmailTemplates,
|
||||
authToken, setAuthToken,
|
||||
}
|
||||
}
|
||||
63
admin-compliance/app/sdk/consent-management/_types.ts
Normal file
63
admin-compliance/app/sdk/consent-management/_types.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
export const API_BASE = '/api/admin/consent'
|
||||
|
||||
export type Tab = 'documents' | 'versions' | 'emails' | 'gdpr' | 'stats'
|
||||
|
||||
export interface Document {
|
||||
id: string
|
||||
type: string
|
||||
name: string
|
||||
description: string
|
||||
mandatory: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface Version {
|
||||
id: string
|
||||
document_id: string
|
||||
version: string
|
||||
language: string
|
||||
title: string
|
||||
content: string
|
||||
status: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
// Email template editor types
|
||||
export interface EmailTemplateData {
|
||||
key: string
|
||||
subject: string
|
||||
body: string
|
||||
}
|
||||
|
||||
export interface ApiEmailTemplate {
|
||||
id: string
|
||||
template_key: string
|
||||
subject: string
|
||||
body: string
|
||||
language: string
|
||||
is_active: boolean
|
||||
}
|
||||
|
||||
export interface ApiGdprProcess {
|
||||
id: string
|
||||
process_key: string
|
||||
title: string
|
||||
description: string
|
||||
legal_basis: string
|
||||
retention_days: number
|
||||
is_active: boolean
|
||||
}
|
||||
|
||||
export interface ConsentStats {
|
||||
activeConsents: number
|
||||
documentCount: number
|
||||
openDSRs: number
|
||||
}
|
||||
|
||||
export interface DsrOverview {
|
||||
open: number
|
||||
completed: number
|
||||
in_progress: number
|
||||
overdue: number
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,53 @@
|
||||
import { AlertTriangle, CheckCircle2, Info } from 'lucide-react'
|
||||
|
||||
const SEVERITY_CONFIG: Record<string, { bg: string; label: string; icon: React.ComponentType<{ className?: string }> }> = {
|
||||
critical: { bg: 'bg-red-100 text-red-800', label: 'Kritisch', icon: AlertTriangle },
|
||||
high: { bg: 'bg-orange-100 text-orange-800', label: 'Hoch', icon: AlertTriangle },
|
||||
medium: { bg: 'bg-yellow-100 text-yellow-800', label: 'Mittel', icon: Info },
|
||||
low: { bg: 'bg-green-100 text-green-800', label: 'Niedrig', icon: CheckCircle2 },
|
||||
}
|
||||
|
||||
export function SeverityBadge({ severity }: { severity: string }) {
|
||||
const config = SEVERITY_CONFIG[severity] || SEVERITY_CONFIG.medium
|
||||
const Icon = config.icon
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium ${config.bg}`}>
|
||||
<Icon className="w-3 h-3" />
|
||||
{config.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function StateBadge({ state }: { state: string }) {
|
||||
const config: Record<string, string> = {
|
||||
draft: 'bg-gray-100 text-gray-600',
|
||||
review: 'bg-blue-100 text-blue-700',
|
||||
approved: 'bg-green-100 text-green-700',
|
||||
deprecated: 'bg-red-100 text-red-600',
|
||||
needs_review: 'bg-yellow-100 text-yellow-800',
|
||||
too_close: 'bg-red-100 text-red-700',
|
||||
duplicate: 'bg-orange-100 text-orange-700',
|
||||
}
|
||||
const labels: Record<string, string> = {
|
||||
needs_review: 'Review noetig',
|
||||
too_close: 'Zu aehnlich',
|
||||
duplicate: 'Duplikat',
|
||||
}
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${config[state] || config.draft}`}>
|
||||
{labels[state] || state}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function LicenseRuleBadge({ rule }: { rule: number | null | undefined }) {
|
||||
if (!rule) return null
|
||||
const config: Record<number, { bg: string; label: string }> = {
|
||||
1: { bg: 'bg-green-100 text-green-700', label: 'Free Use' },
|
||||
2: { bg: 'bg-blue-100 text-blue-700', label: 'Zitation' },
|
||||
3: { bg: 'bg-amber-100 text-amber-700', label: 'Reformuliert' },
|
||||
}
|
||||
const c = config[rule]
|
||||
if (!c) return null
|
||||
return <span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${c.bg}`}>{c.label}</span>
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
Shield, ArrowLeft, ExternalLink, CheckCircle2, Lock,
|
||||
FileText, BookOpen, Scale, Pencil, Trash2, Eye, Clock,
|
||||
} from 'lucide-react'
|
||||
import type { CanonicalControl } from '../_types'
|
||||
import { EFFORT_LABELS } from '../_types'
|
||||
import { SeverityBadge, StateBadge, LicenseRuleBadge } from './Badges'
|
||||
|
||||
export function ControlDetailView({
|
||||
ctrl,
|
||||
onBack,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onReview,
|
||||
}: {
|
||||
ctrl: CanonicalControl
|
||||
onBack: () => void
|
||||
onEdit: () => void
|
||||
onDelete: (controlId: string) => void
|
||||
onReview: (controlId: string, action: string) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<button onClick={onBack} className="flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700">
|
||||
<ArrowLeft className="w-4 h-4" /> Zurueck zur Uebersicht
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={onEdit} className="flex items-center gap-1 px-3 py-1.5 text-sm text-purple-600 border border-purple-300 rounded-lg hover:bg-purple-50">
|
||||
<Pencil className="w-3.5 h-3.5" /> Bearbeiten
|
||||
</button>
|
||||
<button onClick={() => onDelete(ctrl.control_id)} className="flex items-center gap-1 px-3 py-1.5 text-sm text-red-600 border border-red-300 rounded-lg hover:bg-red-50">
|
||||
<Trash2 className="w-3.5 h-3.5" /> Loeschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-start gap-4 mb-6">
|
||||
<div className="flex-shrink-0 w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center">
|
||||
<Shield className="w-6 h-6 text-purple-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-sm font-mono text-purple-600">{ctrl.control_id}</span>
|
||||
<SeverityBadge severity={ctrl.severity} />
|
||||
<StateBadge state={ctrl.release_state} />
|
||||
</div>
|
||||
<h1 className="text-xl font-bold text-gray-900">{ctrl.title}</h1>
|
||||
<div className="flex items-center gap-4 mt-2 text-xs text-gray-500">
|
||||
{ctrl.risk_score !== null && <span>Risiko-Score: {ctrl.risk_score}/10</span>}
|
||||
{ctrl.implementation_effort && <span>Aufwand: {EFFORT_LABELS[ctrl.implementation_effort] || ctrl.implementation_effort}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Objective & Rationale */}
|
||||
<div className="space-y-6">
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Ziel</h3>
|
||||
<p className="text-sm text-gray-700 bg-gray-50 rounded-lg p-4">{ctrl.objective}</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Begruendung</h3>
|
||||
<p className="text-sm text-gray-700 bg-gray-50 rounded-lg p-4">{ctrl.rationale}</p>
|
||||
</section>
|
||||
|
||||
{/* Scope */}
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Geltungsbereich</h3>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{ctrl.scope.platforms && ctrl.scope.platforms.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500 mb-1">Plattformen</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{ctrl.scope.platforms.map(p => (
|
||||
<span key={p} className="px-2 py-0.5 bg-blue-50 text-blue-700 rounded text-xs">{p}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{ctrl.scope.components && ctrl.scope.components.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500 mb-1">Komponenten</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{ctrl.scope.components.map(c => (
|
||||
<span key={c} className="px-2 py-0.5 bg-purple-50 text-purple-700 rounded text-xs">{c}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{ctrl.scope.data_classes && ctrl.scope.data_classes.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500 mb-1">Datenklassen</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{ctrl.scope.data_classes.map(d => (
|
||||
<span key={d} className="px-2 py-0.5 bg-amber-50 text-amber-700 rounded text-xs">{d}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Requirements */}
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Anforderungen</h3>
|
||||
<ol className="space-y-2">
|
||||
{ctrl.requirements.map((req, i) => (
|
||||
<li key={i} className="flex items-start gap-2 text-sm text-gray-700">
|
||||
<span className="flex-shrink-0 w-5 h-5 bg-purple-100 text-purple-700 rounded-full flex items-center justify-center text-xs font-medium mt-0.5">{i + 1}</span>
|
||||
{req}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
{/* Test Procedure */}
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Pruefverfahren</h3>
|
||||
<ol className="space-y-2">
|
||||
{ctrl.test_procedure.map((step, i) => (
|
||||
<li key={i} className="flex items-start gap-2 text-sm text-gray-700">
|
||||
<CheckCircle2 className="w-4 h-4 text-green-500 flex-shrink-0 mt-0.5" />
|
||||
{step}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
{/* Evidence */}
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Nachweisanforderungen</h3>
|
||||
<div className="space-y-2">
|
||||
{ctrl.evidence.map((ev, i) => (
|
||||
<div key={i} className="flex items-start gap-2 p-3 bg-gray-50 rounded-lg">
|
||||
<FileText className="w-4 h-4 text-gray-400 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<span className="text-xs font-medium text-gray-500 uppercase">{ev.type}</span>
|
||||
<p className="text-sm text-gray-700">{ev.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Open Anchors — THE KEY SECTION */}
|
||||
<section className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<BookOpen className="w-4 h-4 text-green-700" />
|
||||
<h3 className="text-sm font-semibold text-green-900">Open-Source-Referenzen</h3>
|
||||
<span className="text-xs text-green-600">({ctrl.open_anchors.length} Quellen)</span>
|
||||
</div>
|
||||
<p className="text-xs text-green-700 mb-3">
|
||||
Dieses Control basiert auf frei verfuegbarem Wissen. Alle Referenzen sind offen und oeffentlich zugaenglich.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{ctrl.open_anchors.map((anchor, i) => (
|
||||
<div key={i} className="flex items-start gap-3 p-2 bg-white rounded border border-green-100">
|
||||
<Scale className="w-4 h-4 text-green-600 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-xs font-semibold text-green-800">{anchor.framework}</span>
|
||||
<p className="text-sm text-gray-700">{anchor.ref}</p>
|
||||
</div>
|
||||
<a
|
||||
href={anchor.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-xs text-green-600 hover:text-green-800 flex-shrink-0"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
Quelle
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Tags */}
|
||||
{ctrl.tags.length > 0 && (
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Tags</h3>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{ctrl.tags.map(tag => (
|
||||
<span key={tag} className="px-2 py-1 bg-gray-100 text-gray-600 rounded text-xs">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* License & Citation Info */}
|
||||
{ctrl.license_rule && (
|
||||
<section className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Scale className="w-4 h-4 text-blue-700" />
|
||||
<h3 className="text-sm font-semibold text-blue-900">Lizenzinformationen</h3>
|
||||
<LicenseRuleBadge rule={ctrl.license_rule} />
|
||||
</div>
|
||||
{ctrl.source_citation && (
|
||||
<div className="text-xs text-blue-800 space-y-1">
|
||||
<p><span className="font-medium">Quelle:</span> {ctrl.source_citation.source}</p>
|
||||
{ctrl.source_citation.license && <p><span className="font-medium">Lizenz:</span> {ctrl.source_citation.license}</p>}
|
||||
{ctrl.source_citation.license_notice && <p><span className="font-medium">Hinweis:</span> {ctrl.source_citation.license_notice}</p>}
|
||||
{ctrl.source_citation.url && (
|
||||
<a href={ctrl.source_citation.url} target="_blank" rel="noopener noreferrer" className="flex items-center gap-1 text-blue-600 hover:text-blue-800">
|
||||
<ExternalLink className="w-3 h-3" /> Originalquelle
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{ctrl.source_original_text && (
|
||||
<details className="mt-2">
|
||||
<summary className="text-xs text-blue-600 cursor-pointer hover:text-blue-800">Originaltext anzeigen</summary>
|
||||
<p className="mt-1 text-xs text-gray-700 bg-white rounded p-2 border border-blue-100 max-h-40 overflow-y-auto">{ctrl.source_original_text}</p>
|
||||
</details>
|
||||
)}
|
||||
{ctrl.license_rule === 3 && (
|
||||
<p className="text-xs text-amber-700 mt-2 flex items-center gap-1">
|
||||
<Lock className="w-3 h-3" />
|
||||
Eigenstaendig formuliert — keine Originalquelle gespeichert
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Generation Metadata (internal) */}
|
||||
{ctrl.generation_metadata && (
|
||||
<section className="bg-gray-50 border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Clock className="w-4 h-4 text-gray-500" />
|
||||
<h3 className="text-sm font-semibold text-gray-700">Generierungsdetails (intern)</h3>
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 space-y-1">
|
||||
<p>Pfad: {String(ctrl.generation_metadata.processing_path || '-')}</p>
|
||||
{ctrl.generation_metadata.similarity_status && (
|
||||
<p className="text-red-600">Similarity: {String(ctrl.generation_metadata.similarity_status)}</p>
|
||||
)}
|
||||
{Array.isArray(ctrl.generation_metadata.similar_controls) && (
|
||||
<div>
|
||||
<p className="font-medium">Aehnliche Controls:</p>
|
||||
{(ctrl.generation_metadata.similar_controls as Array<Record<string, unknown>>).map((s, i) => (
|
||||
<p key={i} className="ml-2">{String(s.control_id)} — {String(s.title)} ({String(s.similarity)})</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Review Actions */}
|
||||
{['needs_review', 'too_close', 'duplicate'].includes(ctrl.release_state) && (
|
||||
<section className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Eye className="w-4 h-4 text-yellow-700" />
|
||||
<h3 className="text-sm font-semibold text-yellow-900">Review erforderlich</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => onReview(ctrl.control_id, 'approve')}
|
||||
className="px-3 py-1.5 text-sm text-white bg-green-600 rounded-lg hover:bg-green-700"
|
||||
>
|
||||
<CheckCircle2 className="w-3.5 h-3.5 inline mr-1" />
|
||||
Akzeptieren
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onReview(ctrl.control_id, 'reject')}
|
||||
className="px-3 py-1.5 text-sm text-white bg-red-600 rounded-lg hover:bg-red-700"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5 inline mr-1" />
|
||||
Ablehnen
|
||||
</button>
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="px-3 py-1.5 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5 inline mr-1" />
|
||||
Ueberarbeiten
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { BookOpen, Trash2, Save, X } from 'lucide-react'
|
||||
import type { ControlFormData } from '../_types'
|
||||
|
||||
export function ControlForm({
|
||||
initial,
|
||||
onSave,
|
||||
onCancel,
|
||||
saving,
|
||||
}: {
|
||||
initial: ControlFormData
|
||||
onSave: (data: ControlFormData) => void
|
||||
onCancel: () => void
|
||||
saving: boolean
|
||||
}) {
|
||||
const [form, setForm] = useState(initial)
|
||||
const [tagInput, setTagInput] = useState(initial.tags.join(', '))
|
||||
const [platformInput, setPlatformInput] = useState((initial.scope.platforms || []).join(', '))
|
||||
const [componentInput, setComponentInput] = useState((initial.scope.components || []).join(', '))
|
||||
const [dataClassInput, setDataClassInput] = useState((initial.scope.data_classes || []).join(', '))
|
||||
|
||||
const handleSave = () => {
|
||||
const data = {
|
||||
...form,
|
||||
tags: tagInput.split(',').map(t => t.trim()).filter(Boolean),
|
||||
scope: {
|
||||
platforms: platformInput.split(',').map(t => t.trim()).filter(Boolean),
|
||||
components: componentInput.split(',').map(t => t.trim()).filter(Boolean),
|
||||
data_classes: dataClassInput.split(',').map(t => t.trim()).filter(Boolean),
|
||||
},
|
||||
requirements: form.requirements.filter(r => r.trim()),
|
||||
test_procedure: form.test_procedure.filter(r => r.trim()),
|
||||
evidence: form.evidence.filter(e => e.type.trim() || e.description.trim()),
|
||||
open_anchors: form.open_anchors.filter(a => a.framework.trim() || a.ref.trim()),
|
||||
}
|
||||
onSave(data)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
{initial.control_id ? `Control ${initial.control_id} bearbeiten` : 'Neues Control erstellen'}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={onCancel} className="px-3 py-1.5 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50">
|
||||
<X className="w-4 h-4 inline mr-1" />Abbrechen
|
||||
</button>
|
||||
<button onClick={handleSave} disabled={saving} className="px-3 py-1.5 text-sm text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50">
|
||||
<Save className="w-4 h-4 inline mr-1" />{saving ? 'Speichern...' : 'Speichern'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Basic fields */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Control-ID *</label>
|
||||
<input
|
||||
value={form.control_id}
|
||||
onChange={e => setForm({ ...form, control_id: e.target.value.toUpperCase() })}
|
||||
placeholder="AUTH-003"
|
||||
disabled={!!initial.control_id}
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:outline-none disabled:bg-gray-100"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">Format: DOMAIN-NNN (z.B. AUTH-003, NET-005)</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Titel *</label>
|
||||
<input
|
||||
value={form.title}
|
||||
onChange={e => setForm({ ...form, title: e.target.value })}
|
||||
placeholder="Control-Titel"
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Schweregrad</label>
|
||||
<select value={form.severity} onChange={e => setForm({ ...form, severity: e.target.value })} className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg">
|
||||
<option value="low">Niedrig</option>
|
||||
<option value="medium">Mittel</option>
|
||||
<option value="high">Hoch</option>
|
||||
<option value="critical">Kritisch</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Risiko-Score (0-10)</label>
|
||||
<input
|
||||
type="number" min="0" max="10" step="0.5"
|
||||
value={form.risk_score ?? ''}
|
||||
onChange={e => setForm({ ...form, risk_score: e.target.value ? parseFloat(e.target.value) : null })}
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Aufwand</label>
|
||||
<select value={form.implementation_effort || ''} onChange={e => setForm({ ...form, implementation_effort: e.target.value || null })} className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg">
|
||||
<option value="">-</option>
|
||||
<option value="s">Klein (S)</option>
|
||||
<option value="m">Mittel (M)</option>
|
||||
<option value="l">Gross (L)</option>
|
||||
<option value="xl">Sehr gross (XL)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Objective & Rationale */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Ziel *</label>
|
||||
<textarea
|
||||
value={form.objective}
|
||||
onChange={e => setForm({ ...form, objective: e.target.value })}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Begruendung *</label>
|
||||
<textarea
|
||||
value={form.rationale}
|
||||
onChange={e => setForm({ ...form, rationale: e.target.value })}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Scope */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Plattformen (komma-getrennt)</label>
|
||||
<input value={platformInput} onChange={e => setPlatformInput(e.target.value)} placeholder="web, mobile, api" className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Komponenten (komma-getrennt)</label>
|
||||
<input value={componentInput} onChange={e => setComponentInput(e.target.value)} placeholder="auth-service, gateway" className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Datenklassen (komma-getrennt)</label>
|
||||
<input value={dataClassInput} onChange={e => setDataClassInput(e.target.value)} placeholder="credentials, tokens" className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Requirements */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="text-xs font-medium text-gray-600">Anforderungen</label>
|
||||
<button onClick={() => setForm({ ...form, requirements: [...form.requirements, ''] })} className="text-xs text-purple-600 hover:text-purple-800">+ Hinzufuegen</button>
|
||||
</div>
|
||||
{form.requirements.map((req, i) => (
|
||||
<div key={i} className="flex gap-2 mb-2">
|
||||
<span className="text-xs text-gray-400 mt-2.5">{i + 1}.</span>
|
||||
<input
|
||||
value={req}
|
||||
onChange={e => { const r = [...form.requirements]; r[i] = e.target.value; setForm({ ...form, requirements: r }) }}
|
||||
className="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-lg"
|
||||
/>
|
||||
<button onClick={() => setForm({ ...form, requirements: form.requirements.filter((_, j) => j !== i) })} className="text-red-400 hover:text-red-600">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Test Procedure */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="text-xs font-medium text-gray-600">Pruefverfahren</label>
|
||||
<button onClick={() => setForm({ ...form, test_procedure: [...form.test_procedure, ''] })} className="text-xs text-purple-600 hover:text-purple-800">+ Hinzufuegen</button>
|
||||
</div>
|
||||
{form.test_procedure.map((step, i) => (
|
||||
<div key={i} className="flex gap-2 mb-2">
|
||||
<span className="text-xs text-gray-400 mt-2.5">{i + 1}.</span>
|
||||
<input
|
||||
value={step}
|
||||
onChange={e => { const t = [...form.test_procedure]; t[i] = e.target.value; setForm({ ...form, test_procedure: t }) }}
|
||||
className="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-lg"
|
||||
/>
|
||||
<button onClick={() => setForm({ ...form, test_procedure: form.test_procedure.filter((_, j) => j !== i) })} className="text-red-400 hover:text-red-600">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Evidence */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="text-xs font-medium text-gray-600">Nachweisanforderungen</label>
|
||||
<button onClick={() => setForm({ ...form, evidence: [...form.evidence, { type: '', description: '' }] })} className="text-xs text-purple-600 hover:text-purple-800">+ Hinzufuegen</button>
|
||||
</div>
|
||||
{form.evidence.map((ev, i) => (
|
||||
<div key={i} className="flex gap-2 mb-2">
|
||||
<input
|
||||
value={ev.type}
|
||||
onChange={e => { const evs = [...form.evidence]; evs[i] = { ...evs[i], type: e.target.value }; setForm({ ...form, evidence: evs }) }}
|
||||
placeholder="Typ (z.B. config, test_result)"
|
||||
className="w-32 px-3 py-2 text-sm border border-gray-300 rounded-lg"
|
||||
/>
|
||||
<input
|
||||
value={ev.description}
|
||||
onChange={e => { const evs = [...form.evidence]; evs[i] = { ...evs[i], description: e.target.value }; setForm({ ...form, evidence: evs }) }}
|
||||
placeholder="Beschreibung"
|
||||
className="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-lg"
|
||||
/>
|
||||
<button onClick={() => setForm({ ...form, evidence: form.evidence.filter((_, j) => j !== i) })} className="text-red-400 hover:text-red-600">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Open Anchors */}
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<BookOpen className="w-4 h-4 text-green-700" />
|
||||
<label className="text-xs font-semibold text-green-900">Open-Source-Referenzen *</label>
|
||||
</div>
|
||||
<button onClick={() => setForm({ ...form, open_anchors: [...form.open_anchors, { framework: '', ref: '', url: '' }] })} className="text-xs text-green-700 hover:text-green-900">+ Hinzufuegen</button>
|
||||
</div>
|
||||
<p className="text-xs text-green-600 mb-3">Jedes Control braucht mindestens eine offene Referenz (OWASP, NIST, ENISA, etc.)</p>
|
||||
{form.open_anchors.map((anchor, i) => (
|
||||
<div key={i} className="flex gap-2 mb-2">
|
||||
<input
|
||||
value={anchor.framework}
|
||||
onChange={e => { const a = [...form.open_anchors]; a[i] = { ...a[i], framework: e.target.value }; setForm({ ...form, open_anchors: a }) }}
|
||||
placeholder="Framework (z.B. OWASP ASVS)"
|
||||
className="w-40 px-3 py-2 text-sm border border-green-200 rounded-lg bg-white"
|
||||
/>
|
||||
<input
|
||||
value={anchor.ref}
|
||||
onChange={e => { const a = [...form.open_anchors]; a[i] = { ...a[i], ref: e.target.value }; setForm({ ...form, open_anchors: a }) }}
|
||||
placeholder="Referenz (z.B. V2.8)"
|
||||
className="w-48 px-3 py-2 text-sm border border-green-200 rounded-lg bg-white"
|
||||
/>
|
||||
<input
|
||||
value={anchor.url}
|
||||
onChange={e => { const a = [...form.open_anchors]; a[i] = { ...a[i], url: e.target.value }; setForm({ ...form, open_anchors: a }) }}
|
||||
placeholder="https://..."
|
||||
className="flex-1 px-3 py-2 text-sm border border-green-200 rounded-lg bg-white"
|
||||
/>
|
||||
<button onClick={() => setForm({ ...form, open_anchors: form.open_anchors.filter((_, j) => j !== i) })} className="text-red-400 hover:text-red-600">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tags & State */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Tags (komma-getrennt)</label>
|
||||
<input value={tagInput} onChange={e => setTagInput(e.target.value)} placeholder="mfa, auth, iam" className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Status</label>
|
||||
<select value={form.release_state} onChange={e => setForm({ ...form, release_state: e.target.value })} className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg">
|
||||
<option value="draft">Draft</option>
|
||||
<option value="review">Review</option>
|
||||
<option value="approved">Approved</option>
|
||||
<option value="deprecated">Deprecated</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
Shield, Search, ChevronRight, Filter, Lock,
|
||||
BookOpen, Plus, Zap, BarChart3,
|
||||
} from 'lucide-react'
|
||||
import type { CanonicalControl, Framework } from '../_types'
|
||||
import { SeverityBadge, StateBadge, LicenseRuleBadge } from './Badges'
|
||||
|
||||
export function ControlListView({
|
||||
controls,
|
||||
filteredControls,
|
||||
frameworks,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
severityFilter,
|
||||
setSeverityFilter,
|
||||
domainFilter,
|
||||
setDomainFilter,
|
||||
stateFilter,
|
||||
setStateFilter,
|
||||
domains,
|
||||
showStats,
|
||||
toggleStats,
|
||||
processedStats,
|
||||
onOpenGenerator,
|
||||
onCreate,
|
||||
onSelect,
|
||||
}: {
|
||||
controls: CanonicalControl[]
|
||||
filteredControls: CanonicalControl[]
|
||||
frameworks: Framework[]
|
||||
searchQuery: string
|
||||
setSearchQuery: (v: string) => void
|
||||
severityFilter: string
|
||||
setSeverityFilter: (v: string) => void
|
||||
domainFilter: string
|
||||
setDomainFilter: (v: string) => void
|
||||
stateFilter: string
|
||||
setStateFilter: (v: string) => void
|
||||
domains: string[]
|
||||
showStats: boolean
|
||||
toggleStats: () => void
|
||||
processedStats: Array<Record<string, unknown>>
|
||||
onOpenGenerator: () => void
|
||||
onCreate: () => void
|
||||
onSelect: (ctrl: CanonicalControl) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="border-b border-gray-200 bg-white px-6 py-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Shield className="w-6 h-6 text-purple-600" />
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-gray-900">Canonical Control Library</h1>
|
||||
<p className="text-xs text-gray-500">
|
||||
{controls.length} unabhaengig formulierte Security Controls —{' '}
|
||||
{controls.reduce((sum, c) => sum + c.open_anchors.length, 0)} Open-Source-Referenzen
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={toggleStats}
|
||||
className="flex items-center gap-1.5 px-3 py-2 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
<BarChart3 className="w-4 h-4" />
|
||||
Stats
|
||||
</button>
|
||||
<button
|
||||
onClick={onOpenGenerator}
|
||||
className="flex items-center gap-1.5 px-3 py-2 text-sm text-white bg-amber-600 rounded-lg hover:bg-amber-700"
|
||||
>
|
||||
<Zap className="w-4 h-4" />
|
||||
Generator
|
||||
</button>
|
||||
<button
|
||||
onClick={onCreate}
|
||||
className="flex items-center gap-1.5 px-3 py-2 text-sm text-white bg-purple-600 rounded-lg hover:bg-purple-700"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Neues Control
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Frameworks */}
|
||||
{frameworks.length > 0 && (
|
||||
<div className="mb-4 p-3 bg-purple-50 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-xs text-purple-700">
|
||||
<Lock className="w-3 h-3" />
|
||||
<span className="font-medium">{frameworks[0]?.name} v{frameworks[0]?.version}</span>
|
||||
<span className="text-purple-500">—</span>
|
||||
<span>{frameworks[0]?.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Controls durchsuchen..."
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-9 pr-4 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Filter className="w-4 h-4 text-gray-400" />
|
||||
</div>
|
||||
<select
|
||||
value={severityFilter}
|
||||
onChange={e => setSeverityFilter(e.target.value)}
|
||||
className="text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
>
|
||||
<option value="">Alle Schweregrade</option>
|
||||
<option value="critical">Kritisch</option>
|
||||
<option value="high">Hoch</option>
|
||||
<option value="medium">Mittel</option>
|
||||
<option value="low">Niedrig</option>
|
||||
</select>
|
||||
<select
|
||||
value={domainFilter}
|
||||
onChange={e => setDomainFilter(e.target.value)}
|
||||
className="text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
>
|
||||
<option value="">Alle Domains</option>
|
||||
{domains.map(d => (
|
||||
<option key={d} value={d}>{d}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={stateFilter}
|
||||
onChange={e => setStateFilter(e.target.value)}
|
||||
className="text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
>
|
||||
<option value="">Alle Status</option>
|
||||
<option value="draft">Draft</option>
|
||||
<option value="approved">Approved</option>
|
||||
<option value="needs_review">Review noetig</option>
|
||||
<option value="too_close">Zu aehnlich</option>
|
||||
<option value="duplicate">Duplikat</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Processing Stats */}
|
||||
{showStats && processedStats.length > 0 && (
|
||||
<div className="mt-3 p-3 bg-gray-50 rounded-lg">
|
||||
<h4 className="text-xs font-semibold text-gray-700 mb-2">Verarbeitungsfortschritt</h4>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{processedStats.map((s, i) => (
|
||||
<div key={i} className="text-xs">
|
||||
<span className="font-medium text-gray-700">{String(s.collection)}</span>
|
||||
<div className="flex gap-2 mt-1 text-gray-500">
|
||||
<span>{String(s.processed_chunks)} verarbeitet</span>
|
||||
<span>{String(s.direct_adopted)} direkt</span>
|
||||
<span>{String(s.llm_reformed)} reformuliert</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Control List */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="space-y-3">
|
||||
{filteredControls.map(ctrl => (
|
||||
<button
|
||||
key={ctrl.control_id}
|
||||
onClick={() => onSelect(ctrl)}
|
||||
className="w-full text-left bg-white border border-gray-200 rounded-lg p-4 hover:border-purple-300 hover:shadow-sm transition-all group"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs font-mono text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded">{ctrl.control_id}</span>
|
||||
<SeverityBadge severity={ctrl.severity} />
|
||||
<StateBadge state={ctrl.release_state} />
|
||||
<LicenseRuleBadge rule={ctrl.license_rule} />
|
||||
{ctrl.risk_score !== null && (
|
||||
<span className="text-xs text-gray-400">Score: {ctrl.risk_score}</span>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-sm font-medium text-gray-900 group-hover:text-purple-700">{ctrl.title}</h3>
|
||||
<p className="text-xs text-gray-500 mt-1 line-clamp-2">{ctrl.objective}</p>
|
||||
|
||||
{/* Open anchors summary */}
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<BookOpen className="w-3 h-3 text-green-600" />
|
||||
<span className="text-xs text-green-700">
|
||||
{ctrl.open_anchors.length} Open-Source-Referenzen:
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{ctrl.open_anchors.map(a => a.framework).filter((v, i, arr) => arr.indexOf(v) === i).join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="w-4 h-4 text-gray-300 group-hover:text-purple-500 flex-shrink-0 mt-1 ml-4" />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{filteredControls.length === 0 && (
|
||||
<div className="text-center py-12 text-gray-400 text-sm">
|
||||
{controls.length === 0
|
||||
? 'Noch keine Controls vorhanden. Klicke auf "Neues Control" um zu starten.'
|
||||
: 'Keine Controls gefunden.'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
'use client'
|
||||
|
||||
import { Zap, X, RefreshCw } from 'lucide-react'
|
||||
|
||||
export function GeneratorModal({
|
||||
genDomain,
|
||||
setGenDomain,
|
||||
genMaxControls,
|
||||
setGenMaxControls,
|
||||
genDryRun,
|
||||
setGenDryRun,
|
||||
generating,
|
||||
genResult,
|
||||
onGenerate,
|
||||
onClose,
|
||||
}: {
|
||||
genDomain: string
|
||||
setGenDomain: (v: string) => void
|
||||
genMaxControls: number
|
||||
setGenMaxControls: (v: number) => void
|
||||
genDryRun: boolean
|
||||
setGenDryRun: (v: boolean) => void
|
||||
generating: boolean
|
||||
genResult: Record<string, unknown> | null
|
||||
onGenerate: () => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg p-6 mx-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="w-5 h-5 text-amber-600" />
|
||||
<h2 className="text-lg font-semibold text-gray-900">Control Generator</h2>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Domain (optional)</label>
|
||||
<select value={genDomain} onChange={e => setGenDomain(e.target.value)} className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg">
|
||||
<option value="">Alle Domains</option>
|
||||
<option value="AUTH">AUTH — Authentifizierung</option>
|
||||
<option value="CRYPT">CRYPT — Kryptographie</option>
|
||||
<option value="NET">NET — Netzwerk</option>
|
||||
<option value="DATA">DATA — Datenschutz</option>
|
||||
<option value="LOG">LOG — Logging</option>
|
||||
<option value="ACC">ACC — Zugriffskontrolle</option>
|
||||
<option value="SEC">SEC — Sicherheit</option>
|
||||
<option value="INC">INC — Incident Response</option>
|
||||
<option value="AI">AI — Kuenstliche Intelligenz</option>
|
||||
<option value="COMP">COMP — Compliance</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Max. Controls: {genMaxControls}</label>
|
||||
<input
|
||||
type="range" min="1" max="100" step="1"
|
||||
value={genMaxControls}
|
||||
onChange={e => setGenMaxControls(parseInt(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="dryRun"
|
||||
checked={genDryRun}
|
||||
onChange={e => setGenDryRun(e.target.checked)}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
<label htmlFor="dryRun" className="text-sm text-gray-700">Dry Run (Vorschau ohne Speicherung)</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onGenerate}
|
||||
disabled={generating}
|
||||
className="w-full py-2 text-sm text-white bg-amber-600 rounded-lg hover:bg-amber-700 disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
{generating ? (
|
||||
<><RefreshCw className="w-4 h-4 animate-spin" /> Generiere...</>
|
||||
) : (
|
||||
<><Zap className="w-4 h-4" /> Generierung starten</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Results */}
|
||||
{genResult && (
|
||||
<div className={`p-4 rounded-lg text-sm ${genResult.status === 'error' ? 'bg-red-50 text-red-800' : 'bg-green-50 text-green-800'}`}>
|
||||
<p className="font-medium mb-1">{String(genResult.message || genResult.status)}</p>
|
||||
{genResult.status !== 'error' && (
|
||||
<div className="grid grid-cols-2 gap-1 text-xs mt-2">
|
||||
<span>Chunks gescannt: {String(genResult.total_chunks_scanned)}</span>
|
||||
<span>Controls generiert: {String(genResult.controls_generated)}</span>
|
||||
<span>Verifiziert: {String(genResult.controls_verified)}</span>
|
||||
<span>Review noetig: {String(genResult.controls_needs_review)}</span>
|
||||
<span>Zu aehnlich: {String(genResult.controls_too_close)}</span>
|
||||
<span>Duplikate: {String(genResult.controls_duplicates_found)}</span>
|
||||
</div>
|
||||
)}
|
||||
{Array.isArray(genResult.errors) && (genResult.errors as string[]).length > 0 && (
|
||||
<div className="mt-2 text-xs text-red-600">
|
||||
{(genResult.errors as string[]).slice(0, 3).map((e, i) => <p key={i}>{e}</p>)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
87
admin-compliance/app/sdk/control-library/_types.ts
Normal file
87
admin-compliance/app/sdk/control-library/_types.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
export interface OpenAnchor {
|
||||
framework: string
|
||||
ref: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface EvidenceItem {
|
||||
type: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface CanonicalControl {
|
||||
id: string
|
||||
framework_id: string
|
||||
control_id: string
|
||||
title: string
|
||||
objective: string
|
||||
rationale: string
|
||||
scope: {
|
||||
platforms?: string[]
|
||||
components?: string[]
|
||||
data_classes?: string[]
|
||||
}
|
||||
requirements: string[]
|
||||
test_procedure: string[]
|
||||
evidence: EvidenceItem[]
|
||||
severity: string
|
||||
risk_score: number | null
|
||||
implementation_effort: string | null
|
||||
evidence_confidence: number | null
|
||||
open_anchors: OpenAnchor[]
|
||||
release_state: string
|
||||
tags: string[]
|
||||
license_rule?: number | null
|
||||
source_original_text?: string | null
|
||||
source_citation?: Record<string, string> | null
|
||||
customer_visible?: boolean
|
||||
generation_metadata?: Record<string, unknown> | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface Framework {
|
||||
id: string
|
||||
framework_id: string
|
||||
name: string
|
||||
version: string
|
||||
description: string
|
||||
release_state: string
|
||||
}
|
||||
|
||||
export const EFFORT_LABELS: Record<string, string> = {
|
||||
s: 'Klein (S)',
|
||||
m: 'Mittel (M)',
|
||||
l: 'Gross (L)',
|
||||
xl: 'Sehr gross (XL)',
|
||||
}
|
||||
|
||||
export const BACKEND_URL = '/api/sdk/v1/canonical'
|
||||
|
||||
export const EMPTY_CONTROL = {
|
||||
framework_id: 'bp_security_v1',
|
||||
control_id: '',
|
||||
title: '',
|
||||
objective: '',
|
||||
rationale: '',
|
||||
scope: { platforms: [] as string[], components: [] as string[], data_classes: [] as string[] },
|
||||
requirements: [''],
|
||||
test_procedure: [''],
|
||||
evidence: [{ type: '', description: '' }],
|
||||
severity: 'medium',
|
||||
risk_score: null as number | null,
|
||||
implementation_effort: 'm' as string | null,
|
||||
open_anchors: [{ framework: '', ref: '', url: '' }],
|
||||
release_state: 'draft',
|
||||
tags: [] as string[],
|
||||
}
|
||||
|
||||
export type ControlFormData = typeof EMPTY_CONTROL
|
||||
|
||||
export function getDomain(controlId: string): string {
|
||||
return controlId.split('-')[0] || ''
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
189
admin-compliance/app/sdk/dsb-portal/_components/AufgabenTab.tsx
Normal file
189
admin-compliance/app/sdk/dsb-portal/_components/AufgabenTab.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import {
|
||||
Task, TASK_CATEGORIES, PRIORITY_LABELS, PRIORITY_COLORS,
|
||||
TASK_STATUS_LABELS, TASK_STATUS_COLORS, apiFetch, formatDate,
|
||||
} from './types'
|
||||
import {
|
||||
Skeleton, Modal, Badge, FormLabel, FormInput, FormTextarea,
|
||||
FormSelect, PrimaryButton, SecondaryButton, ErrorState, EmptyState,
|
||||
IconTask, IconPlus, IconCheck, IconCalendar,
|
||||
} from './ui-primitives'
|
||||
|
||||
export function AufgabenTab({
|
||||
assignmentId,
|
||||
addToast,
|
||||
}: {
|
||||
assignmentId: string
|
||||
addToast: (msg: string, type?: 'success' | 'error') => void
|
||||
}) {
|
||||
const [tasks, setTasks] = useState<Task[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all')
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const [newTitle, setNewTitle] = useState('')
|
||||
const [newDesc, setNewDesc] = useState('')
|
||||
const [newCategory, setNewCategory] = useState(TASK_CATEGORIES[0])
|
||||
const [newPriority, setNewPriority] = useState('medium')
|
||||
const [newDueDate, setNewDueDate] = useState('')
|
||||
|
||||
const fetchTasks = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const params = statusFilter !== 'all' ? `?status=${statusFilter}` : ''
|
||||
const data = await apiFetch<Task[]>(`/api/sdk/v1/dsb/assignments/${assignmentId}/tasks${params}`)
|
||||
setTasks(data)
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Laden der Aufgaben')
|
||||
} finally { setLoading(false) }
|
||||
}, [assignmentId, statusFilter])
|
||||
|
||||
useEffect(() => { fetchTasks() }, [fetchTasks])
|
||||
|
||||
const handleCreateTask = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
try {
|
||||
await apiFetch<Task>(`/api/sdk/v1/dsb/assignments/${assignmentId}/tasks`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
title: newTitle, description: newDesc, category: newCategory,
|
||||
priority: newPriority, due_date: newDueDate || null,
|
||||
}),
|
||||
})
|
||||
addToast('Aufgabe erstellt')
|
||||
setShowModal(false)
|
||||
setNewTitle(''); setNewDesc(''); setNewCategory(TASK_CATEGORIES[0])
|
||||
setNewPriority('medium'); setNewDueDate('')
|
||||
fetchTasks()
|
||||
} catch (e: unknown) {
|
||||
addToast(e instanceof Error ? e.message : 'Fehler', 'error')
|
||||
} finally { setSaving(false) }
|
||||
}
|
||||
|
||||
const handleCompleteTask = async (taskId: string) => {
|
||||
try {
|
||||
await apiFetch(`/api/sdk/v1/dsb/tasks/${taskId}/complete`, { method: 'POST' })
|
||||
addToast('Aufgabe abgeschlossen')
|
||||
fetchTasks()
|
||||
} catch (e: unknown) {
|
||||
addToast(e instanceof Error ? e.message : 'Fehler', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const statusFilters = [
|
||||
{ value: 'all', label: 'Alle' },
|
||||
{ value: 'open', label: 'Offen' },
|
||||
{ value: 'in_progress', label: 'In Bearbeitung' },
|
||||
{ value: 'completed', label: 'Erledigt' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-4">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{statusFilters.map((f) => (
|
||||
<button key={f.value} onClick={() => setStatusFilter(f.value)}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
|
||||
statusFilter === f.value ? 'bg-purple-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}>{f.label}</button>
|
||||
))}
|
||||
</div>
|
||||
<PrimaryButton onClick={() => setShowModal(true)} className="flex items-center gap-1.5">
|
||||
<IconPlus /> Neue Aufgabe
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{loading ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-16 rounded-lg" />)}
|
||||
</div>
|
||||
) : error ? (
|
||||
<ErrorState message={error} onRetry={fetchTasks} />
|
||||
) : tasks.length === 0 ? (
|
||||
<EmptyState icon={<IconTask className="w-7 h-7" />} title="Keine Aufgaben"
|
||||
description="Erstellen Sie eine neue Aufgabe um zu beginnen." />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{tasks.map((task) => (
|
||||
<div key={task.id} className="bg-white border border-gray-200 rounded-lg p-4 hover:border-purple-300 transition-colors">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h4 className={`font-medium ${task.status === 'completed' ? 'line-through text-gray-400' : 'text-gray-900'}`}>
|
||||
{task.title}
|
||||
</h4>
|
||||
<Badge label={task.category} className="bg-purple-50 text-purple-600 border-purple-200" />
|
||||
<Badge label={PRIORITY_LABELS[task.priority] || task.priority}
|
||||
className={PRIORITY_COLORS[task.priority] || 'bg-gray-100 text-gray-500'} />
|
||||
<Badge label={TASK_STATUS_LABELS[task.status] || task.status}
|
||||
className={TASK_STATUS_COLORS[task.status] || 'bg-gray-100 text-gray-500'} />
|
||||
</div>
|
||||
{task.description && <p className="text-sm text-gray-500 mt-1 line-clamp-2">{task.description}</p>}
|
||||
<div className="flex items-center gap-3 mt-2 text-xs text-gray-400">
|
||||
{task.due_date && (
|
||||
<span className="flex items-center gap-1"><IconCalendar className="w-3 h-3" />Frist: {formatDate(task.due_date)}</span>
|
||||
)}
|
||||
<span>Erstellt: {formatDate(task.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{task.status !== 'completed' && task.status !== 'cancelled' && (
|
||||
<button onClick={() => handleCompleteTask(task.id)} title="Aufgabe abschliessen"
|
||||
className="p-2 rounded-lg text-green-600 hover:bg-green-50 transition-colors flex-shrink-0">
|
||||
<IconCheck className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create task modal */}
|
||||
<Modal open={showModal} onClose={() => setShowModal(false)} title="Neue Aufgabe erstellen">
|
||||
<form onSubmit={handleCreateTask} className="space-y-4">
|
||||
<div>
|
||||
<FormLabel htmlFor="task-title">Titel *</FormLabel>
|
||||
<FormInput id="task-title" value={newTitle} onChange={setNewTitle} placeholder="Aufgabentitel" required />
|
||||
</div>
|
||||
<div>
|
||||
<FormLabel htmlFor="task-desc">Beschreibung</FormLabel>
|
||||
<FormTextarea id="task-desc" value={newDesc} onChange={setNewDesc} placeholder="Beschreibung der Aufgabe..." />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<FormLabel htmlFor="task-cat">Kategorie</FormLabel>
|
||||
<FormSelect id="task-cat" value={newCategory} onChange={setNewCategory}
|
||||
options={TASK_CATEGORIES.map((c) => ({ value: c, label: c }))} />
|
||||
</div>
|
||||
<div>
|
||||
<FormLabel htmlFor="task-prio">Prioritaet</FormLabel>
|
||||
<FormSelect id="task-prio" value={newPriority} onChange={setNewPriority}
|
||||
options={[
|
||||
{ value: 'urgent', label: 'Dringend' }, { value: 'high', label: 'Hoch' },
|
||||
{ value: 'medium', label: 'Mittel' }, { value: 'low', label: 'Niedrig' },
|
||||
]} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<FormLabel htmlFor="task-due">Faelligkeitsdatum</FormLabel>
|
||||
<FormInput id="task-due" type="date" value={newDueDate} onChange={setNewDueDate} />
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<SecondaryButton onClick={() => setShowModal(false)}>Abbrechen</SecondaryButton>
|
||||
<PrimaryButton type="submit" disabled={saving || !newTitle.trim()}>
|
||||
{saving ? 'Erstelle...' : 'Aufgabe erstellen'}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
126
admin-compliance/app/sdk/dsb-portal/_components/DetailView.tsx
Normal file
126
admin-compliance/app/sdk/dsb-portal/_components/DetailView.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { AssignmentOverview, ASSIGNMENT_STATUS_LABELS, ASSIGNMENT_STATUS_COLORS, formatDate } from './types'
|
||||
import {
|
||||
Badge, ComplianceBar, HoursBar,
|
||||
IconBack, IconTask, IconClock, IconMail, IconSettings, IconShield,
|
||||
} from './ui-primitives'
|
||||
import { AufgabenTab } from './AufgabenTab'
|
||||
import { ZeiterfassungTab } from './ZeiterfassungTab'
|
||||
import { KommunikationTab } from './KommunikationTab'
|
||||
import { EinstellungenTab } from './EinstellungenTab'
|
||||
|
||||
type DetailTab = 'aufgaben' | 'zeit' | 'kommunikation' | 'einstellungen'
|
||||
|
||||
export function DetailView({
|
||||
assignment,
|
||||
onBack,
|
||||
onUpdate,
|
||||
addToast,
|
||||
}: {
|
||||
assignment: AssignmentOverview
|
||||
onBack: () => void
|
||||
onUpdate: () => void
|
||||
addToast: (msg: string, type?: 'success' | 'error') => void
|
||||
}) {
|
||||
const [activeTab, setActiveTab] = useState<DetailTab>('aufgaben')
|
||||
|
||||
const tabs: { id: DetailTab; label: string; icon: React.ReactNode }[] = [
|
||||
{ id: 'aufgaben', label: 'Aufgaben', icon: <IconTask className="w-4 h-4" /> },
|
||||
{ id: 'zeit', label: 'Zeiterfassung', icon: <IconClock className="w-4 h-4" /> },
|
||||
{ id: 'kommunikation', label: 'Kommunikation', icon: <IconMail className="w-4 h-4" /> },
|
||||
{ id: 'einstellungen', label: 'Einstellungen', icon: <IconSettings className="w-4 h-4" /> },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Back + Header */}
|
||||
<div>
|
||||
<button onClick={onBack}
|
||||
className="flex items-center gap-2 text-sm text-purple-600 hover:text-purple-800 font-medium mb-4 transition-colors">
|
||||
<IconBack className="w-4 h-4" /> Zurueck zur Uebersicht
|
||||
</button>
|
||||
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-purple-100 flex items-center justify-center text-purple-600">
|
||||
<IconShield className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-900">{assignment.tenant_name}</h2>
|
||||
<p className="text-sm text-gray-400 font-mono">{assignment.tenant_slug}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
label={ASSIGNMENT_STATUS_LABELS[assignment.status] || assignment.status}
|
||||
className={ASSIGNMENT_STATUS_COLORS[assignment.status] || 'bg-gray-100 text-gray-600'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Meta info */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-5 pt-5 border-t border-gray-100">
|
||||
<div>
|
||||
<p className="text-xs text-gray-400">Vertragsbeginn</p>
|
||||
<p className="text-sm font-medium text-gray-700">{formatDate(assignment.contract_start)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-400">Vertragsende</p>
|
||||
<p className="text-sm font-medium text-gray-700">
|
||||
{assignment.contract_end ? formatDate(assignment.contract_end) : 'Unbefristet'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-400">Compliance-Score</p>
|
||||
<div className="mt-1"><ComplianceBar score={assignment.compliance_score} /></div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-400">Stunden diesen Monat</p>
|
||||
<div className="mt-1"><HoursBar used={assignment.hours_this_month} budget={assignment.hours_budget} /></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{assignment.notes && (
|
||||
<div className="mt-4 pt-4 border-t border-gray-100">
|
||||
<p className="text-xs text-gray-400 mb-1">Anmerkungen</p>
|
||||
<p className="text-sm text-gray-600">{assignment.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-gray-200">
|
||||
<nav className="flex gap-0 -mb-px overflow-x-auto">
|
||||
{tabs.map((tab) => (
|
||||
<button key={tab.id} onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex items-center gap-2 px-5 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
|
||||
activeTab === tab.id
|
||||
? 'border-purple-600 text-purple-700'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}>
|
||||
{tab.icon} {tab.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
<div>
|
||||
{activeTab === 'aufgaben' && <AufgabenTab assignmentId={assignment.id} addToast={addToast} />}
|
||||
{activeTab === 'zeit' && (
|
||||
<ZeiterfassungTab assignmentId={assignment.id} monthlyBudget={assignment.monthly_hours_budget} addToast={addToast} />
|
||||
)}
|
||||
{activeTab === 'kommunikation' && <KommunikationTab assignmentId={assignment.id} addToast={addToast} />}
|
||||
{activeTab === 'einstellungen' && (
|
||||
<EinstellungenTab assignment={assignment} onUpdate={onUpdate} addToast={addToast} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { AssignmentOverview, ASSIGNMENT_STATUS_LABELS, apiFetch } from './types'
|
||||
import { FormLabel, FormInput, FormTextarea, PrimaryButton } from './ui-primitives'
|
||||
|
||||
export function EinstellungenTab({
|
||||
assignment,
|
||||
onUpdate,
|
||||
addToast,
|
||||
}: {
|
||||
assignment: AssignmentOverview
|
||||
onUpdate: () => void
|
||||
addToast: (msg: string, type?: 'success' | 'error') => void
|
||||
}) {
|
||||
const [status, setStatus] = useState(assignment.status)
|
||||
const [budget, setBudget] = useState(String(assignment.monthly_hours_budget))
|
||||
const [notes, setNotes] = useState(assignment.notes || '')
|
||||
const [contractStart, setContractStart] = useState(assignment.contract_start?.slice(0, 10) || '')
|
||||
const [contractEnd, setContractEnd] = useState(assignment.contract_end?.slice(0, 10) || '')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
await apiFetch(`/api/sdk/v1/dsb/assignments/${assignment.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
status,
|
||||
monthly_hours_budget: parseFloat(budget) || 0,
|
||||
notes,
|
||||
contract_start: contractStart || null,
|
||||
contract_end: contractEnd || null,
|
||||
}),
|
||||
})
|
||||
addToast('Einstellungen gespeichert')
|
||||
onUpdate()
|
||||
} catch (e: unknown) {
|
||||
addToast(e instanceof Error ? e.message : 'Fehler beim Speichern', 'error')
|
||||
} finally { setSaving(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl space-y-6">
|
||||
{/* Status */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-5">
|
||||
<h4 className="text-sm font-semibold text-gray-700 mb-3">Status</h4>
|
||||
<div className="flex gap-2">
|
||||
{(['active', 'paused', 'terminated'] as const).map((s) => (
|
||||
<button key={s} onClick={() => setStatus(s)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium border transition-colors ${
|
||||
status === s
|
||||
? s === 'active' ? 'bg-green-100 text-green-700 border-green-300'
|
||||
: s === 'paused' ? 'bg-yellow-100 text-yellow-700 border-yellow-300'
|
||||
: 'bg-red-100 text-red-700 border-red-300'
|
||||
: 'bg-white text-gray-500 border-gray-200 hover:bg-gray-50'
|
||||
}`}>
|
||||
{ASSIGNMENT_STATUS_LABELS[s]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contract period */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-5">
|
||||
<h4 className="text-sm font-semibold text-gray-700 mb-3">Vertragszeitraum</h4>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<FormLabel htmlFor="s-start">Vertragsbeginn</FormLabel>
|
||||
<FormInput id="s-start" type="date" value={contractStart} onChange={setContractStart} />
|
||||
</div>
|
||||
<div>
|
||||
<FormLabel htmlFor="s-end">Vertragsende</FormLabel>
|
||||
<FormInput id="s-end" type="date" value={contractEnd} onChange={setContractEnd} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Budget */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-5">
|
||||
<h4 className="text-sm font-semibold text-gray-700 mb-3">Monatliches Stundenbudget</h4>
|
||||
<div className="max-w-xs">
|
||||
<FormInput type="number" value={budget} onChange={setBudget} min={0} max={999} step={1} />
|
||||
<p className="text-xs text-gray-400 mt-1">Stunden pro Monat</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-5">
|
||||
<h4 className="text-sm font-semibold text-gray-700 mb-3">Anmerkungen</h4>
|
||||
<FormTextarea value={notes} onChange={setNotes} placeholder="Interne Anmerkungen zum Mandat..." rows={4} />
|
||||
</div>
|
||||
|
||||
{/* Save */}
|
||||
<div className="flex justify-end">
|
||||
<PrimaryButton onClick={handleSave} disabled={saving}>
|
||||
{saving ? 'Speichere...' : 'Einstellungen speichern'}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { Communication, COMM_CHANNELS, apiFetch, formatDateTime } from './types'
|
||||
import {
|
||||
Skeleton, Modal, Badge, FormLabel, FormInput, FormTextarea,
|
||||
FormSelect, PrimaryButton, SecondaryButton, ErrorState, EmptyState,
|
||||
IconMail, IconPlus, IconInbound, IconOutbound,
|
||||
} from './ui-primitives'
|
||||
|
||||
const CHANNEL_COLORS: Record<string, string> = {
|
||||
'E-Mail': 'bg-blue-100 text-blue-700 border-blue-200',
|
||||
'Telefon': 'bg-green-100 text-green-700 border-green-200',
|
||||
'Besprechung': 'bg-purple-100 text-purple-700 border-purple-200',
|
||||
'Portal': 'bg-indigo-100 text-indigo-700 border-indigo-200',
|
||||
'Brief': 'bg-orange-100 text-orange-700 border-orange-200',
|
||||
}
|
||||
|
||||
export function KommunikationTab({
|
||||
assignmentId,
|
||||
addToast,
|
||||
}: {
|
||||
assignmentId: string
|
||||
addToast: (msg: string, type?: 'success' | 'error') => void
|
||||
}) {
|
||||
const [comms, setComms] = useState<Communication[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const [formDirection, setFormDirection] = useState('outbound')
|
||||
const [formChannel, setFormChannel] = useState(COMM_CHANNELS[0])
|
||||
const [formSubject, setFormSubject] = useState('')
|
||||
const [formContent, setFormContent] = useState('')
|
||||
const [formParticipants, setFormParticipants] = useState('')
|
||||
|
||||
const fetchComms = useCallback(async () => {
|
||||
setLoading(true); setError('')
|
||||
try {
|
||||
const data = await apiFetch<Communication[]>(
|
||||
`/api/sdk/v1/dsb/assignments/${assignmentId}/communications`,
|
||||
)
|
||||
setComms(data)
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Laden der Kommunikation')
|
||||
} finally { setLoading(false) }
|
||||
}, [assignmentId])
|
||||
|
||||
useEffect(() => { fetchComms() }, [fetchComms])
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault(); setSaving(true)
|
||||
try {
|
||||
await apiFetch(`/api/sdk/v1/dsb/assignments/${assignmentId}/communications`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
direction: formDirection, channel: formChannel,
|
||||
subject: formSubject, content: formContent, participants: formParticipants,
|
||||
}),
|
||||
})
|
||||
addToast('Kommunikation erfasst'); setShowModal(false)
|
||||
setFormDirection('outbound'); setFormChannel(COMM_CHANNELS[0])
|
||||
setFormSubject(''); setFormContent(''); setFormParticipants('')
|
||||
fetchComms()
|
||||
} catch (e: unknown) {
|
||||
addToast(e instanceof Error ? e.message : 'Fehler', 'error')
|
||||
} finally { setSaving(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<p className="text-sm text-gray-500">Kommunikations-Protokoll</p>
|
||||
<PrimaryButton onClick={() => setShowModal(true)} className="flex items-center gap-1.5">
|
||||
<IconPlus /> Kommunikation erfassen
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => <Skeleton key={i} className="h-20 rounded-lg" />)}
|
||||
</div>
|
||||
) : error ? (
|
||||
<ErrorState message={error} onRetry={fetchComms} />
|
||||
) : comms.length === 0 ? (
|
||||
<EmptyState icon={<IconMail className="w-7 h-7" />} title="Keine Kommunikation"
|
||||
description="Erfassen Sie die erste Kommunikation mit dem Mandanten." />
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{comms.map((comm) => (
|
||||
<div key={comm.id} className="bg-white border border-gray-200 rounded-xl p-4 hover:border-purple-200 transition-colors">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 ${
|
||||
comm.direction === 'inbound' ? 'bg-blue-100 text-blue-600' : 'bg-green-100 text-green-600'
|
||||
}`}>
|
||||
{comm.direction === 'inbound' ? <IconInbound className="w-4 h-4" /> : <IconOutbound className="w-4 h-4" />}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-medium text-gray-900 text-sm">{comm.subject}</span>
|
||||
<Badge label={comm.channel}
|
||||
className={CHANNEL_COLORS[comm.channel] || 'bg-gray-100 text-gray-600 border-gray-200'} />
|
||||
<span className="text-xs text-gray-400">
|
||||
{comm.direction === 'inbound' ? 'Eingehend' : 'Ausgehend'}
|
||||
</span>
|
||||
</div>
|
||||
{comm.content && <p className="text-sm text-gray-500 mt-1 line-clamp-2">{comm.content}</p>}
|
||||
<div className="flex items-center gap-3 mt-2 text-xs text-gray-400">
|
||||
<span>{formatDateTime(comm.created_at)}</span>
|
||||
{comm.participants && <span>Teilnehmer: {comm.participants}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create communication modal */}
|
||||
<Modal open={showModal} onClose={() => setShowModal(false)} title="Kommunikation erfassen">
|
||||
<form onSubmit={handleCreate} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<FormLabel htmlFor="comm-dir">Richtung</FormLabel>
|
||||
<FormSelect id="comm-dir" value={formDirection} onChange={setFormDirection}
|
||||
options={[{ value: 'outbound', label: 'Ausgehend' }, { value: 'inbound', label: 'Eingehend' }]} />
|
||||
</div>
|
||||
<div>
|
||||
<FormLabel htmlFor="comm-ch">Kanal</FormLabel>
|
||||
<FormSelect id="comm-ch" value={formChannel} onChange={setFormChannel}
|
||||
options={COMM_CHANNELS.map((c) => ({ value: c, label: c }))} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<FormLabel htmlFor="comm-subj">Betreff *</FormLabel>
|
||||
<FormInput id="comm-subj" value={formSubject} onChange={setFormSubject}
|
||||
placeholder="Betreff der Kommunikation" required />
|
||||
</div>
|
||||
<div>
|
||||
<FormLabel htmlFor="comm-content">Inhalt</FormLabel>
|
||||
<FormTextarea id="comm-content" value={formContent} onChange={setFormContent}
|
||||
placeholder="Inhalt / Zusammenfassung..." rows={4} />
|
||||
</div>
|
||||
<div>
|
||||
<FormLabel htmlFor="comm-parts">Teilnehmer</FormLabel>
|
||||
<FormInput id="comm-parts" value={formParticipants} onChange={setFormParticipants}
|
||||
placeholder="z.B. Herr Mueller, Frau Schmidt" />
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<SecondaryButton onClick={() => setShowModal(false)}>Abbrechen</SecondaryButton>
|
||||
<PrimaryButton type="submit" disabled={saving || !formSubject.trim()}>
|
||||
{saving ? 'Speichere...' : 'Kommunikation erfassen'}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { AssignmentOverview, ASSIGNMENT_STATUS_LABELS, ASSIGNMENT_STATUS_COLORS, formatDate } from './types'
|
||||
import { Badge, ComplianceBar, HoursBar, IconTask, IconCalendar } from './ui-primitives'
|
||||
|
||||
export function MandantCard({
|
||||
assignment,
|
||||
onClick,
|
||||
}: {
|
||||
assignment: AssignmentOverview
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className="bg-white rounded-xl border border-gray-200 p-5 hover:border-purple-400 hover:shadow-lg cursor-pointer transition-all group"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="min-w-0">
|
||||
<h3 className="font-semibold text-gray-900 truncate group-hover:text-purple-700 transition-colors">
|
||||
{assignment.tenant_name}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-400 font-mono">{assignment.tenant_slug}</p>
|
||||
</div>
|
||||
<Badge
|
||||
label={ASSIGNMENT_STATUS_LABELS[assignment.status] || assignment.status}
|
||||
className={ASSIGNMENT_STATUS_COLORS[assignment.status] || 'bg-gray-100 text-gray-600'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Compliance Score */}
|
||||
<div className="mb-3">
|
||||
<div className="flex items-center justify-between text-xs text-gray-500 mb-1">
|
||||
<span>Compliance-Score</span>
|
||||
</div>
|
||||
<ComplianceBar score={assignment.compliance_score} />
|
||||
</div>
|
||||
|
||||
{/* Hours */}
|
||||
<div className="mb-3">
|
||||
<div className="flex items-center justify-between text-xs text-gray-500 mb-1">
|
||||
<span>Stunden diesen Monat</span>
|
||||
</div>
|
||||
<HoursBar used={assignment.hours_this_month} budget={assignment.hours_budget} />
|
||||
</div>
|
||||
|
||||
{/* Footer: Tasks */}
|
||||
<div className="flex items-center justify-between pt-3 border-t border-gray-100">
|
||||
<div className="flex items-center gap-1 text-sm text-gray-600">
|
||||
<IconTask className="w-4 h-4" />
|
||||
<span>{assignment.open_task_count} offene Aufgaben</span>
|
||||
</div>
|
||||
{assignment.urgent_task_count > 0 && (
|
||||
<Badge label={`${assignment.urgent_task_count} dringend`} className="bg-red-100 text-red-700 border-red-200" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Next deadline */}
|
||||
{assignment.next_deadline && (
|
||||
<div className="flex items-center gap-1 mt-2 text-xs text-gray-400">
|
||||
<IconCalendar className="w-3 h-3" />
|
||||
<span>Naechste Frist: {formatDate(assignment.next_deadline)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import {
|
||||
HourEntry, HoursSummary, HOUR_CATEGORIES,
|
||||
apiFetch, formatDate, currentMonth, monthLabel, prevMonth, nextMonth,
|
||||
} from './types'
|
||||
import {
|
||||
Skeleton, Modal, Badge, HoursBar, FormLabel, FormInput,
|
||||
FormTextarea, FormSelect, PrimaryButton, SecondaryButton,
|
||||
ErrorState, EmptyState, IconClock, IconPlus,
|
||||
} from './ui-primitives'
|
||||
|
||||
export function ZeiterfassungTab({
|
||||
assignmentId,
|
||||
monthlyBudget,
|
||||
addToast,
|
||||
}: {
|
||||
assignmentId: string
|
||||
monthlyBudget: number
|
||||
addToast: (msg: string, type?: 'success' | 'error') => void
|
||||
}) {
|
||||
const [hours, setHours] = useState<HourEntry[]>([])
|
||||
const [summary, setSummary] = useState<HoursSummary | null>(null)
|
||||
const [month, setMonth] = useState(currentMonth())
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const [formDate, setFormDate] = useState(new Date().toISOString().slice(0, 10))
|
||||
const [formHours, setFormHours] = useState('1')
|
||||
const [formCategory, setFormCategory] = useState(HOUR_CATEGORIES[0])
|
||||
const [formDesc, setFormDesc] = useState('')
|
||||
const [formBillable, setFormBillable] = useState(true)
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true); setError('')
|
||||
try {
|
||||
const [hoursData, summaryData] = await Promise.all([
|
||||
apiFetch<HourEntry[]>(`/api/sdk/v1/dsb/assignments/${assignmentId}/hours?month=${month}`),
|
||||
apiFetch<HoursSummary>(`/api/sdk/v1/dsb/assignments/${assignmentId}/hours/summary?month=${month}`),
|
||||
])
|
||||
setHours(hoursData); setSummary(summaryData)
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Laden der Zeiterfassung')
|
||||
} finally { setLoading(false) }
|
||||
}, [assignmentId, month])
|
||||
|
||||
useEffect(() => { fetchData() }, [fetchData])
|
||||
|
||||
const handleLogHours = async (e: React.FormEvent) => {
|
||||
e.preventDefault(); setSaving(true)
|
||||
try {
|
||||
await apiFetch(`/api/sdk/v1/dsb/assignments/${assignmentId}/hours`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
date: formDate, hours: parseFloat(formHours),
|
||||
category: formCategory, description: formDesc, billable: formBillable,
|
||||
}),
|
||||
})
|
||||
addToast('Stunden erfasst'); setShowModal(false)
|
||||
setFormDate(new Date().toISOString().slice(0, 10))
|
||||
setFormHours('1'); setFormCategory(HOUR_CATEGORIES[0])
|
||||
setFormDesc(''); setFormBillable(true); fetchData()
|
||||
} catch (e: unknown) {
|
||||
addToast(e instanceof Error ? e.message : 'Fehler', 'error')
|
||||
} finally { setSaving(false) }
|
||||
}
|
||||
|
||||
const maxCatHours = summary ? Math.max(...Object.values(summary.by_category), 1) : 1
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => setMonth(prevMonth(month))}
|
||||
className="p-2 rounded-lg hover:bg-gray-100 text-gray-500 transition-colors">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="text-sm font-medium text-gray-700 min-w-[140px] text-center">{monthLabel(month)}</span>
|
||||
<button onClick={() => setMonth(nextMonth(month))}
|
||||
className="p-2 rounded-lg hover:bg-gray-100 text-gray-500 transition-colors">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<PrimaryButton onClick={() => setShowModal(true)} className="flex items-center gap-1.5">
|
||||
<IconPlus /> Stunden erfassen
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-24 rounded-lg" />
|
||||
<Skeleton className="h-40 rounded-lg" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<ErrorState message={error} onRetry={fetchData} />
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Summary cards */}
|
||||
{summary && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="bg-purple-50 rounded-xl p-4 border border-purple-200">
|
||||
<p className="text-xs text-purple-600 font-medium">Gesamt-Stunden</p>
|
||||
<p className="text-2xl font-bold text-purple-900 mt-1">{summary.total_hours}h</p>
|
||||
<div className="mt-2"><HoursBar used={summary.total_hours} budget={monthlyBudget} /></div>
|
||||
</div>
|
||||
<div className="bg-green-50 rounded-xl p-4 border border-green-200">
|
||||
<p className="text-xs text-green-600 font-medium">Abrechnungsfaehig</p>
|
||||
<p className="text-2xl font-bold text-green-900 mt-1">{summary.billable_hours}h</p>
|
||||
<p className="text-xs text-green-500 mt-1">
|
||||
{summary.total_hours > 0
|
||||
? `${Math.round((summary.billable_hours / summary.total_hours) * 100)}% der Gesamtstunden`
|
||||
: 'Keine Stunden erfasst'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-xl p-4 border border-gray-200">
|
||||
<p className="text-xs text-gray-500 font-medium">Budget verbleibend</p>
|
||||
<p className="text-2xl font-bold text-gray-900 mt-1">{Math.max(monthlyBudget - summary.total_hours, 0)}h</p>
|
||||
<p className="text-xs text-gray-400 mt-1">von {monthlyBudget}h Monatsbudget</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hours by category */}
|
||||
{summary && Object.keys(summary.by_category).length > 0 && (
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-5">
|
||||
<h4 className="text-sm font-semibold text-gray-700 mb-3">Stunden nach Kategorie</h4>
|
||||
<div className="space-y-2.5">
|
||||
{Object.entries(summary.by_category).sort(([, a], [, b]) => b - a).map(([cat, h]) => (
|
||||
<div key={cat} className="flex items-center gap-3">
|
||||
<span className="text-xs text-gray-500 min-w-[120px] truncate">{cat}</span>
|
||||
<div className="flex-1 h-5 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-purple-400 rounded-full transition-all"
|
||||
style={{ width: `${(h / maxCatHours) * 100}%` }} />
|
||||
</div>
|
||||
<span className="text-xs font-medium text-gray-700 min-w-[40px] text-right">{h}h</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hours table */}
|
||||
{hours.length === 0 ? (
|
||||
<EmptyState icon={<IconClock className="w-7 h-7" />} title="Keine Stunden erfasst"
|
||||
description={`Fuer ${monthLabel(month)} wurden noch keine Stunden erfasst.`} />
|
||||
) : (
|
||||
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 border-b border-gray-200">
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase">Datum</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase">Stunden</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase">Kategorie</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase">Beschreibung</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase">Abrechenbar</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{hours.map((entry) => (
|
||||
<tr key={entry.id} className="hover:bg-gray-50 transition-colors">
|
||||
<td className="px-4 py-3 text-gray-700 whitespace-nowrap">{formatDate(entry.date)}</td>
|
||||
<td className="px-4 py-3 font-medium text-gray-900">{entry.hours}h</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge label={entry.category} className="bg-purple-50 text-purple-600 border-purple-200" />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-600 max-w-xs truncate">{entry.description || '-'}</td>
|
||||
<td className="px-4 py-3">
|
||||
{entry.billable
|
||||
? <span className="text-green-600 font-medium">Ja</span>
|
||||
: <span className="text-gray-400">Nein</span>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Log hours modal */}
|
||||
<Modal open={showModal} onClose={() => setShowModal(false)} title="Stunden erfassen">
|
||||
<form onSubmit={handleLogHours} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<FormLabel htmlFor="h-date">Datum *</FormLabel>
|
||||
<FormInput id="h-date" type="date" value={formDate} onChange={setFormDate} required />
|
||||
</div>
|
||||
<div>
|
||||
<FormLabel htmlFor="h-hours">Stunden *</FormLabel>
|
||||
<FormInput id="h-hours" type="number" value={formHours} onChange={setFormHours}
|
||||
min={0.25} max={24} step={0.25} required />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<FormLabel htmlFor="h-cat">Kategorie</FormLabel>
|
||||
<FormSelect id="h-cat" value={formCategory} onChange={setFormCategory}
|
||||
options={HOUR_CATEGORIES.map((c) => ({ value: c, label: c }))} />
|
||||
</div>
|
||||
<div>
|
||||
<FormLabel htmlFor="h-desc">Beschreibung</FormLabel>
|
||||
<FormTextarea id="h-desc" value={formDesc} onChange={setFormDesc} placeholder="Was wurde gemacht..." />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input id="h-billable" type="checkbox" checked={formBillable}
|
||||
onChange={(e) => setFormBillable(e.target.checked)}
|
||||
className="rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||
<label htmlFor="h-billable" className="text-sm text-gray-700">Abrechnungsfaehig</label>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<SecondaryButton onClick={() => setShowModal(false)}>Abbrechen</SecondaryButton>
|
||||
<PrimaryButton type="submit" disabled={saving}>
|
||||
{saving ? 'Erfasse...' : 'Stunden erfassen'}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
199
admin-compliance/app/sdk/dsb-portal/_components/types.ts
Normal file
199
admin-compliance/app/sdk/dsb-portal/_components/types.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
export interface AssignmentOverview {
|
||||
id: string
|
||||
dsb_user_id: string
|
||||
tenant_id: string
|
||||
tenant_name: string
|
||||
tenant_slug: string
|
||||
status: string
|
||||
contract_start: string
|
||||
contract_end: string | null
|
||||
monthly_hours_budget: number
|
||||
notes: string
|
||||
compliance_score: number
|
||||
hours_this_month: number
|
||||
hours_budget: number
|
||||
open_task_count: number
|
||||
urgent_task_count: number
|
||||
next_deadline: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface DSBDashboard {
|
||||
assignments: AssignmentOverview[]
|
||||
total_assignments: number
|
||||
active_assignments: number
|
||||
total_hours_this_month: number
|
||||
open_tasks: number
|
||||
urgent_tasks: number
|
||||
generated_at: string
|
||||
}
|
||||
|
||||
export interface HourEntry {
|
||||
id: string
|
||||
assignment_id: string
|
||||
date: string
|
||||
hours: number
|
||||
category: string
|
||||
description: string
|
||||
billable: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface Task {
|
||||
id: string
|
||||
assignment_id: string
|
||||
title: string
|
||||
description: string
|
||||
category: string
|
||||
priority: string
|
||||
status: string
|
||||
due_date: string | null
|
||||
completed_at: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface Communication {
|
||||
id: string
|
||||
assignment_id: string
|
||||
direction: string
|
||||
channel: string
|
||||
subject: string
|
||||
content: string
|
||||
participants: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface HoursSummary {
|
||||
total_hours: number
|
||||
billable_hours: number
|
||||
by_category: Record<string, number>
|
||||
period: string
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CONSTANTS
|
||||
// =============================================================================
|
||||
|
||||
export const DSB_USER_ID = '00000000-0000-0000-0000-000000000001'
|
||||
|
||||
export const TASK_CATEGORIES = [
|
||||
'DSFA-Pruefung', 'Betroffenenanfrage', 'Vorfall-Pruefung',
|
||||
'Audit-Vorbereitung', 'Richtlinien-Pruefung', 'Schulung',
|
||||
'Beratung', 'Sonstiges',
|
||||
]
|
||||
|
||||
export const HOUR_CATEGORIES = [
|
||||
'DSFA-Pruefung', 'Beratung', 'Audit', 'Schulung',
|
||||
'Vorfallreaktion', 'Dokumentation', 'Besprechung', 'Sonstiges',
|
||||
]
|
||||
|
||||
export const COMM_CHANNELS = ['E-Mail', 'Telefon', 'Besprechung', 'Portal', 'Brief']
|
||||
|
||||
export const PRIORITY_LABELS: Record<string, string> = {
|
||||
urgent: 'Dringend', high: 'Hoch', medium: 'Mittel', low: 'Niedrig',
|
||||
}
|
||||
|
||||
export const PRIORITY_COLORS: Record<string, string> = {
|
||||
urgent: 'bg-red-100 text-red-700 border-red-200',
|
||||
high: 'bg-orange-100 text-orange-700 border-orange-200',
|
||||
medium: 'bg-blue-100 text-blue-700 border-blue-200',
|
||||
low: 'bg-gray-100 text-gray-500 border-gray-200',
|
||||
}
|
||||
|
||||
export const TASK_STATUS_LABELS: Record<string, string> = {
|
||||
open: 'Offen', in_progress: 'In Bearbeitung', waiting: 'Wartend',
|
||||
completed: 'Erledigt', cancelled: 'Abgebrochen',
|
||||
}
|
||||
|
||||
export const TASK_STATUS_COLORS: Record<string, string> = {
|
||||
open: 'bg-blue-100 text-blue-700',
|
||||
in_progress: 'bg-yellow-100 text-yellow-700',
|
||||
waiting: 'bg-orange-100 text-orange-700',
|
||||
completed: 'bg-green-100 text-green-700',
|
||||
cancelled: 'bg-gray-100 text-gray-500',
|
||||
}
|
||||
|
||||
export const ASSIGNMENT_STATUS_COLORS: Record<string, string> = {
|
||||
active: 'bg-green-100 text-green-700 border-green-300',
|
||||
paused: 'bg-yellow-100 text-yellow-700 border-yellow-300',
|
||||
terminated: 'bg-red-100 text-red-700 border-red-300',
|
||||
}
|
||||
|
||||
export const ASSIGNMENT_STATUS_LABELS: Record<string, string> = {
|
||||
active: 'Aktiv', paused: 'Pausiert', terminated: 'Beendet',
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// API HELPERS
|
||||
// =============================================================================
|
||||
|
||||
export async function apiFetch<T>(url: string, options?: RequestInit): Promise<T> {
|
||||
const res = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-User-ID': DSB_USER_ID,
|
||||
...(options?.headers || {}),
|
||||
},
|
||||
})
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '')
|
||||
throw new Error(`API Error ${res.status}: ${text || res.statusText}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// DATE HELPERS
|
||||
// =============================================================================
|
||||
|
||||
export function formatDate(dateStr: string | null): string {
|
||||
if (!dateStr) return '-'
|
||||
try {
|
||||
return new Date(dateStr).toLocaleDateString('de-DE', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
})
|
||||
} catch { return dateStr }
|
||||
}
|
||||
|
||||
export function formatDateTime(dateStr: string | null): string {
|
||||
if (!dateStr) return '-'
|
||||
try {
|
||||
return new Date(dateStr).toLocaleDateString('de-DE', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
})
|
||||
} catch { return dateStr }
|
||||
}
|
||||
|
||||
export function currentMonth(): string {
|
||||
const d = new Date()
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function monthLabel(ym: string): string {
|
||||
const [y, m] = ym.split('-')
|
||||
const months = [
|
||||
'Januar', 'Februar', 'Maerz', 'April', 'Mai', 'Juni',
|
||||
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember',
|
||||
]
|
||||
return `${months[parseInt(m, 10) - 1]} ${y}`
|
||||
}
|
||||
|
||||
export function prevMonth(ym: string): string {
|
||||
const [y, m] = ym.split('-').map(Number)
|
||||
if (m === 1) return `${y - 1}-12`
|
||||
return `${y}-${String(m - 1).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function nextMonth(ym: string): string {
|
||||
const [y, m] = ym.split('-').map(Number)
|
||||
if (m === 12) return `${y + 1}-01`
|
||||
return `${y}-${String(m + 1).padStart(2, '0')}`
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
||||
|
||||
// =============================================================================
|
||||
// TOAST
|
||||
// =============================================================================
|
||||
|
||||
export interface ToastMessage {
|
||||
id: number
|
||||
message: string
|
||||
type: 'success' | 'error'
|
||||
}
|
||||
|
||||
let toastIdCounter = 0
|
||||
|
||||
export function useToast() {
|
||||
const [toasts, setToasts] = useState<ToastMessage[]>([])
|
||||
|
||||
const addToast = useCallback((message: string, type: 'success' | 'error' = 'success') => {
|
||||
const id = ++toastIdCounter
|
||||
setToasts((prev) => [...prev, { id, message, type }])
|
||||
setTimeout(() => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id))
|
||||
}, 3500)
|
||||
}, [])
|
||||
|
||||
return { toasts, addToast }
|
||||
}
|
||||
|
||||
export function ToastContainer({ toasts }: { toasts: ToastMessage[] }) {
|
||||
if (toasts.length === 0) return null
|
||||
return (
|
||||
<div className="fixed top-4 right-4 z-[100] flex flex-col gap-2">
|
||||
{toasts.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
className={`px-5 py-3 rounded-lg shadow-lg text-sm font-medium text-white transition-all animate-slide-in ${
|
||||
t.type === 'success' ? 'bg-green-600' : 'bg-red-600'
|
||||
}`}
|
||||
>
|
||||
{t.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// LOADING SKELETON
|
||||
// =============================================================================
|
||||
|
||||
export function Skeleton({ className = '' }: { className?: string }) {
|
||||
return <div className={`animate-pulse bg-gray-200 rounded ${className}`} />
|
||||
}
|
||||
|
||||
export function DashboardSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-28 rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-64 rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MODAL
|
||||
// =============================================================================
|
||||
|
||||
export function Modal({
|
||||
open, onClose, title, children, maxWidth = 'max-w-lg',
|
||||
}: {
|
||||
open: boolean; onClose: () => void; title: string
|
||||
children: React.ReactNode; maxWidth?: string
|
||||
}) {
|
||||
const overlayRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const handleEsc = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
|
||||
document.addEventListener('keydown', handleEsc)
|
||||
return () => document.removeEventListener('keydown', handleEsc)
|
||||
}, [open, onClose])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div ref={overlayRef}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"
|
||||
onClick={(e) => { if (e.target === overlayRef.current) onClose() }}>
|
||||
<div className={`bg-white rounded-2xl shadow-2xl w-full ${maxWidth} mx-4 overflow-hidden`}>
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 bg-purple-50">
|
||||
<h3 className="text-lg font-semibold text-purple-900">{title}</h3>
|
||||
<button onClick={onClose} className="p-1 rounded-lg hover:bg-purple-100 transition-colors text-purple-600">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-6 py-4 max-h-[70vh] overflow-y-auto">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// STAT CARD, PROGRESS BARS, BADGE
|
||||
// =============================================================================
|
||||
|
||||
export function StatCard({
|
||||
title, value, icon, accent = false,
|
||||
}: {
|
||||
title: string; value: string | number; icon: React.ReactNode; accent?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className={`rounded-xl border p-5 transition-all ${
|
||||
accent ? 'bg-red-50 border-red-200' : 'bg-white border-gray-200 hover:border-purple-300'
|
||||
}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${
|
||||
accent ? 'bg-red-100 text-red-600' : 'bg-purple-100 text-purple-600'
|
||||
}`}>{icon}</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">{title}</p>
|
||||
<p className={`text-2xl font-bold ${accent ? 'text-red-700' : 'text-gray-900'}`}>{value}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ComplianceBar({ score }: { score: number }) {
|
||||
const color = score < 40 ? 'bg-red-500' : score < 70 ? 'bg-yellow-500' : 'bg-green-500'
|
||||
const textColor = score < 40 ? 'text-red-700' : score < 70 ? 'text-yellow-700' : 'text-green-700'
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-2.5 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div className={`h-full rounded-full transition-all ${color}`} style={{ width: `${Math.min(score, 100)}%` }} />
|
||||
</div>
|
||||
<span className={`text-xs font-semibold min-w-[36px] text-right ${textColor}`}>{score}%</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function HoursBar({ used, budget }: { used: number; budget: number }) {
|
||||
const pct = budget > 0 ? Math.min((used / budget) * 100, 100) : 0
|
||||
const over = used > budget
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-2.5 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div className={`h-full rounded-full transition-all ${over ? 'bg-red-500' : 'bg-purple-500'}`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className={`text-xs font-medium min-w-[60px] text-right ${over ? 'text-red-600' : 'text-gray-600'}`}>
|
||||
{used}h / {budget}h
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Badge({ label, className = '' }: { label: string; className?: string }) {
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 text-xs font-medium rounded-full border ${className}`}>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// FORM COMPONENTS
|
||||
// =============================================================================
|
||||
|
||||
export function FormLabel({ children, htmlFor }: { children: React.ReactNode; htmlFor?: string }) {
|
||||
return <label htmlFor={htmlFor} className="block text-sm font-medium text-gray-700 mb-1">{children}</label>
|
||||
}
|
||||
|
||||
export function FormInput({
|
||||
id, type = 'text', value, onChange, placeholder, required, min, max, step,
|
||||
}: {
|
||||
id?: string; type?: string; value: string | number; onChange: (val: string) => void
|
||||
placeholder?: string; required?: boolean; min?: string | number; max?: string | number; step?: string | number
|
||||
}) {
|
||||
return (
|
||||
<input id={id} type={type} value={value} onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder} required={required} min={min} max={max} step={step}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500" />
|
||||
)
|
||||
}
|
||||
|
||||
export function FormTextarea({
|
||||
id, value, onChange, placeholder, rows = 3,
|
||||
}: { id?: string; value: string; onChange: (val: string) => void; placeholder?: string; rows?: number }) {
|
||||
return (
|
||||
<textarea id={id} value={value} onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder} rows={rows}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500 resize-none" />
|
||||
)
|
||||
}
|
||||
|
||||
export function FormSelect({
|
||||
id, value, onChange, options, placeholder,
|
||||
}: {
|
||||
id?: string; value: string; onChange: (val: string) => void
|
||||
options: { value: string; label: string }[]; placeholder?: string
|
||||
}) {
|
||||
return (
|
||||
<select id={id} value={value} onChange={(e) => onChange(e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500 bg-white">
|
||||
{placeholder && <option value="" disabled>{placeholder}</option>}
|
||||
{options.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
)
|
||||
}
|
||||
|
||||
export function PrimaryButton({
|
||||
onClick, disabled, children, type = 'button', className = '',
|
||||
}: {
|
||||
onClick?: () => void; disabled?: boolean; children: React.ReactNode
|
||||
type?: 'button' | 'submit'; className?: string
|
||||
}) {
|
||||
return (
|
||||
<button type={type} onClick={onClick} disabled={disabled}
|
||||
className={`px-4 py-2 rounded-lg bg-purple-600 text-white text-sm font-medium hover:bg-purple-700 focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors ${className}`}>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function SecondaryButton({
|
||||
onClick, disabled, children, type = 'button', className = '',
|
||||
}: {
|
||||
onClick?: () => void; disabled?: boolean; children: React.ReactNode
|
||||
type?: 'button' | 'submit'; className?: string
|
||||
}) {
|
||||
return (
|
||||
<button type={type} onClick={onClick} disabled={disabled}
|
||||
className={`px-4 py-2 rounded-lg border border-gray-300 text-gray-700 text-sm font-medium hover:bg-gray-50 focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors ${className}`}>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ERROR / EMPTY STATES
|
||||
// =============================================================================
|
||||
|
||||
export function ErrorState({ message, onRetry }: { message: string; onRetry: () => void }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-red-100 flex items-center justify-center mb-4">
|
||||
<svg className="w-8 h-8 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-gray-700 font-medium mb-1">Fehler beim Laden</p>
|
||||
<p className="text-sm text-gray-500 mb-4 max-w-md">{message}</p>
|
||||
<PrimaryButton onClick={onRetry}>Erneut versuchen</PrimaryButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function EmptyState({ icon, title, description }: { icon: React.ReactNode; title: string; description: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div className="w-14 h-14 rounded-full bg-purple-50 flex items-center justify-center mb-3 text-purple-400">{icon}</div>
|
||||
<p className="text-gray-700 font-medium">{title}</p>
|
||||
<p className="text-sm text-gray-400 mt-1">{description}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ICONS
|
||||
// =============================================================================
|
||||
|
||||
export function IconUsers({ className = 'w-5 h-5' }: { className?: string }) {
|
||||
return (<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
</svg>)
|
||||
}
|
||||
|
||||
export function IconClock({ className = 'w-5 h-5' }: { className?: string }) {
|
||||
return (<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>)
|
||||
}
|
||||
|
||||
export function IconTask({ className = 'w-5 h-5' }: { className?: string }) {
|
||||
return (<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
|
||||
</svg>)
|
||||
}
|
||||
|
||||
export function IconAlert({ className = 'w-5 h-5' }: { className?: string }) {
|
||||
return (<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>)
|
||||
}
|
||||
|
||||
export function IconBack({ className = 'w-5 h-5' }: { className?: string }) {
|
||||
return (<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>)
|
||||
}
|
||||
|
||||
export function IconPlus({ className = 'w-4 h-4' }: { className?: string }) {
|
||||
return (<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>)
|
||||
}
|
||||
|
||||
export function IconCheck({ className = 'w-4 h-4' }: { className?: string }) {
|
||||
return (<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>)
|
||||
}
|
||||
|
||||
export function IconMail({ className = 'w-5 h-5' }: { className?: string }) {
|
||||
return (<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>)
|
||||
}
|
||||
|
||||
export function IconSettings({ className = 'w-5 h-5' }: { className?: string }) {
|
||||
return (<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>)
|
||||
}
|
||||
|
||||
export function IconRefresh({ className = 'w-4 h-4' }: { className?: string }) {
|
||||
return (<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>)
|
||||
}
|
||||
|
||||
export function IconInbound({ className = 'w-4 h-4' }: { className?: string }) {
|
||||
return (<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
|
||||
</svg>)
|
||||
}
|
||||
|
||||
export function IconOutbound({ className = 'w-4 h-4' }: { className?: string }) {
|
||||
return (<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 5l7 7-7 7M5 5l7 7-7 7" />
|
||||
</svg>)
|
||||
}
|
||||
|
||||
export function IconShield({ className = 'w-6 h-6' }: { className?: string }) {
|
||||
return (<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>)
|
||||
}
|
||||
|
||||
export function IconCalendar({ className = 'w-4 h-4' }: { className?: string }) {
|
||||
return (<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,205 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { DSFARisk } from '@/lib/sdk/dsfa/types'
|
||||
import type { SDMGoal } from '@/lib/sdk/dsfa/types'
|
||||
import {
|
||||
MITIGATION_LIBRARY,
|
||||
MITIGATION_TYPE_LABELS,
|
||||
SDM_GOAL_LABELS,
|
||||
EFFECTIVENESS_LABELS,
|
||||
} from '@/lib/sdk/dsfa/mitigation-library'
|
||||
import type { CatalogMitigation } from '@/lib/sdk/dsfa/mitigation-library'
|
||||
|
||||
export function AddMitigationModal({
|
||||
risks,
|
||||
onClose,
|
||||
onAdd,
|
||||
}: {
|
||||
risks: DSFARisk[]
|
||||
onClose: () => void
|
||||
onAdd: (data: { risk_id: string; description: string; type: string; responsible_party: string }) => void
|
||||
}) {
|
||||
const [mode, setMode] = useState<'library' | 'manual'>('library')
|
||||
const [riskId, setRiskId] = useState(risks[0]?.id || '')
|
||||
const [type, setType] = useState('technical')
|
||||
const [description, setDescription] = useState('')
|
||||
const [responsibleParty, setResponsibleParty] = useState('')
|
||||
const [typeFilter, setTypeFilter] = useState<'all' | 'technical' | 'organizational' | 'legal'>('all')
|
||||
const [sdmFilter, setSdmFilter] = useState<SDMGoal | 'all'>('all')
|
||||
|
||||
const filteredLibrary = MITIGATION_LIBRARY.filter(m => {
|
||||
if (typeFilter !== 'all' && m.type !== typeFilter) return false
|
||||
if (sdmFilter !== 'all' && !m.sdmGoals.includes(sdmFilter)) return false
|
||||
return true
|
||||
})
|
||||
|
||||
function selectCatalogMitigation(m: CatalogMitigation) {
|
||||
setType(m.type)
|
||||
setDescription(`${m.title}\n\n${m.description}\n\nRechtsgrundlage: ${m.legalBasis}`)
|
||||
setMode('manual')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl p-6 max-w-2xl w-full mx-4 max-h-[85vh] overflow-y-auto">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Massnahme hinzufuegen</h3>
|
||||
|
||||
{/* Tab Toggle */}
|
||||
<div className="flex border-b mb-4">
|
||||
<button
|
||||
onClick={() => setMode('library')}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px ${
|
||||
mode === 'library' ? 'border-purple-500 text-purple-600' : 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
Aus Bibliothek waehlen ({MITIGATION_LIBRARY.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMode('manual')}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px ${
|
||||
mode === 'manual' ? 'border-purple-500 text-purple-600' : 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
Manuell eingeben
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mode === 'library' ? (
|
||||
<div className="space-y-3">
|
||||
{/* Filters */}
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
value={typeFilter}
|
||||
onChange={e => setTypeFilter(e.target.value as typeof typeFilter)}
|
||||
className="text-sm border rounded px-2 py-1"
|
||||
>
|
||||
<option value="all">Alle Typen</option>
|
||||
{Object.entries(MITIGATION_TYPE_LABELS).map(([key, label]) => (
|
||||
<option key={key} value={key}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={sdmFilter}
|
||||
onChange={e => setSdmFilter(e.target.value as SDMGoal | 'all')}
|
||||
className="text-sm border rounded px-2 py-1"
|
||||
>
|
||||
<option value="all">Alle SDM-Ziele</option>
|
||||
{Object.entries(SDM_GOAL_LABELS).map(([key, label]) => (
|
||||
<option key={key} value={key}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Library List */}
|
||||
<div className="max-h-[40vh] overflow-y-auto space-y-2">
|
||||
{filteredLibrary.map(m => (
|
||||
<button
|
||||
key={m.id}
|
||||
onClick={() => selectCatalogMitigation(m)}
|
||||
className="w-full text-left p-3 rounded-lg border hover:border-purple-300 hover:bg-purple-50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs font-mono text-gray-400">{m.id}</span>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${
|
||||
m.type === 'technical' ? 'bg-blue-50 text-blue-600' :
|
||||
m.type === 'organizational' ? 'bg-green-50 text-green-600' :
|
||||
'bg-purple-50 text-purple-600'
|
||||
}`}>
|
||||
{MITIGATION_TYPE_LABELS[m.type]}
|
||||
</span>
|
||||
{m.sdmGoals.map(g => (
|
||||
<span key={g} className="text-xs px-2 py-0.5 rounded-full bg-gray-100 text-gray-600">
|
||||
{SDM_GOAL_LABELS[g]}
|
||||
</span>
|
||||
))}
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${
|
||||
m.effectiveness === 'high' ? 'bg-green-50 text-green-700' :
|
||||
m.effectiveness === 'medium' ? 'bg-yellow-50 text-yellow-700' :
|
||||
'bg-gray-50 text-gray-500'
|
||||
}`}>
|
||||
{EFFECTIVENESS_LABELS[m.effectiveness]}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm font-medium text-gray-900">{m.title}</div>
|
||||
<div className="text-xs text-gray-500 mt-1 line-clamp-2">{m.description}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{filteredLibrary.length === 0 && (
|
||||
<p className="text-sm text-gray-500 text-center py-4">Keine Massnahmen fuer die gewaehlten Filter.</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Zugehoeriges Risiko</label>
|
||||
<select
|
||||
value={riskId}
|
||||
onChange={(e) => setRiskId(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
>
|
||||
{risks.map(risk => (
|
||||
<option key={risk.id} value={risk.id}>
|
||||
{risk.description.substring(0, 50)}...
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Typ</label>
|
||||
<select
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
>
|
||||
<option value="technical">Technisch</option>
|
||||
<option value="organizational">Organisatorisch</option>
|
||||
<option value="legal">Rechtlich</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Beschreibung</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
rows={3}
|
||||
placeholder="Beschreiben Sie die Massnahme..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Verantwortlich</label>
|
||||
<input
|
||||
type="text"
|
||||
value={responsibleParty}
|
||||
onChange={(e) => setResponsibleParty(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
placeholder="Name oder Rolle..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 mt-6">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 py-2 px-4 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onAdd({ risk_id: riskId, description, type, responsible_party: responsibleParty })}
|
||||
disabled={!description.trim() || mode === 'library'}
|
||||
className="flex-1 py-2 px-4 bg-purple-600 text-white rounded-lg font-medium hover:bg-purple-700 disabled:opacity-50"
|
||||
>
|
||||
Hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
182
admin-compliance/app/sdk/dsfa/[id]/_components/AddRiskModal.tsx
Normal file
182
admin-compliance/app/sdk/dsfa/[id]/_components/AddRiskModal.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import {
|
||||
DSFA_RISK_LEVEL_LABELS,
|
||||
calculateRiskLevel,
|
||||
} from '@/lib/sdk/dsfa/types'
|
||||
import type { DSFARiskCategory } from '@/lib/sdk/dsfa/types'
|
||||
import type { SDMGoal } from '@/lib/sdk/dsfa/types'
|
||||
import {
|
||||
RISK_CATALOG,
|
||||
RISK_CATEGORY_LABELS,
|
||||
} from '@/lib/sdk/dsfa/risk-catalog'
|
||||
import type { CatalogRisk } from '@/lib/sdk/dsfa/risk-catalog'
|
||||
import { SDM_GOAL_LABELS } from '@/lib/sdk/dsfa/mitigation-library'
|
||||
|
||||
export function AddRiskModal({
|
||||
likelihood,
|
||||
impact,
|
||||
onClose,
|
||||
onAdd,
|
||||
}: {
|
||||
likelihood: 'low' | 'medium' | 'high'
|
||||
impact: 'low' | 'medium' | 'high'
|
||||
onClose: () => void
|
||||
onAdd: (data: { category: string; description: string }) => void
|
||||
}) {
|
||||
const [mode, setMode] = useState<'catalog' | 'manual'>('catalog')
|
||||
const [category, setCategory] = useState('confidentiality')
|
||||
const [description, setDescription] = useState('')
|
||||
const [catalogFilter, setCatalogFilter] = useState<DSFARiskCategory | 'all'>('all')
|
||||
const [sdmFilter, setSdmFilter] = useState<SDMGoal | 'all'>('all')
|
||||
|
||||
const { level } = calculateRiskLevel(likelihood, impact)
|
||||
|
||||
const filteredCatalog = RISK_CATALOG.filter(r => {
|
||||
if (catalogFilter !== 'all' && r.category !== catalogFilter) return false
|
||||
if (sdmFilter !== 'all' && r.sdmGoal !== sdmFilter) return false
|
||||
return true
|
||||
})
|
||||
|
||||
function selectCatalogRisk(risk: CatalogRisk) {
|
||||
setCategory(risk.category)
|
||||
setDescription(`${risk.title}\n\n${risk.description}`)
|
||||
setMode('manual')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl p-6 max-w-2xl w-full mx-4 max-h-[85vh] overflow-y-auto">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Risiko hinzufuegen</h3>
|
||||
|
||||
<div className="mb-4 p-3 rounded-lg bg-gray-50">
|
||||
<div className="text-sm text-gray-500">
|
||||
Eintrittswahrscheinlichkeit: <span className="font-medium text-gray-700">{likelihood === 'low' ? 'Niedrig' : likelihood === 'medium' ? 'Mittel' : 'Hoch'}</span>
|
||||
{' | '}
|
||||
Auswirkung: <span className="font-medium text-gray-700">{impact === 'low' ? 'Niedrig' : impact === 'medium' ? 'Mittel' : 'Hoch'}</span>
|
||||
{' | '}
|
||||
Risikostufe: <span className="font-medium">{DSFA_RISK_LEVEL_LABELS[level]}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab Toggle */}
|
||||
<div className="flex border-b mb-4">
|
||||
<button
|
||||
onClick={() => setMode('catalog')}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px ${
|
||||
mode === 'catalog' ? 'border-purple-500 text-purple-600' : 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
Aus Katalog waehlen ({RISK_CATALOG.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMode('manual')}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px ${
|
||||
mode === 'manual' ? 'border-purple-500 text-purple-600' : 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
Manuell eingeben
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mode === 'catalog' ? (
|
||||
<div className="space-y-3">
|
||||
{/* Filters */}
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
value={catalogFilter}
|
||||
onChange={e => setCatalogFilter(e.target.value as DSFARiskCategory | 'all')}
|
||||
className="text-sm border rounded px-2 py-1"
|
||||
>
|
||||
<option value="all">Alle Kategorien</option>
|
||||
{Object.entries(RISK_CATEGORY_LABELS).map(([key, label]) => (
|
||||
<option key={key} value={key}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={sdmFilter}
|
||||
onChange={e => setSdmFilter(e.target.value as SDMGoal | 'all')}
|
||||
className="text-sm border rounded px-2 py-1"
|
||||
>
|
||||
<option value="all">Alle SDM-Ziele</option>
|
||||
{Object.entries(SDM_GOAL_LABELS).map(([key, label]) => (
|
||||
<option key={key} value={key}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Catalog List */}
|
||||
<div className="max-h-[40vh] overflow-y-auto space-y-2">
|
||||
{filteredCatalog.map(risk => (
|
||||
<button
|
||||
key={risk.id}
|
||||
onClick={() => selectCatalogRisk(risk)}
|
||||
className="w-full text-left p-3 rounded-lg border hover:border-purple-300 hover:bg-purple-50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs font-mono text-gray-400">{risk.id}</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-gray-100 text-gray-600">
|
||||
{RISK_CATEGORY_LABELS[risk.category]}
|
||||
</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-blue-50 text-blue-600">
|
||||
{SDM_GOAL_LABELS[risk.sdmGoal]}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm font-medium text-gray-900">{risk.title}</div>
|
||||
<div className="text-xs text-gray-500 mt-1 line-clamp-2">{risk.description}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{filteredCatalog.length === 0 && (
|
||||
<p className="text-sm text-gray-500 text-center py-4">Keine Risiken fuer die gewaehlten Filter.</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Kategorie</label>
|
||||
<select
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
>
|
||||
<option value="confidentiality">Vertraulichkeit</option>
|
||||
<option value="integrity">Integritaet</option>
|
||||
<option value="availability">Verfuegbarkeit</option>
|
||||
<option value="rights_freedoms">Rechte & Freiheiten</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Beschreibung</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
rows={4}
|
||||
placeholder="Beschreiben Sie das Risiko..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 mt-6">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 py-2 px-4 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onAdd({ category, description })}
|
||||
disabled={!description.trim() || mode === 'catalog'}
|
||||
className="flex-1 py-2 px-4 bg-purple-600 text-white rounded-lg font-medium hover:bg-purple-700 disabled:opacity-50"
|
||||
>
|
||||
Hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
|
||||
interface RAGSearchResult {
|
||||
text: string
|
||||
regulation_code: string
|
||||
regulation_name: string
|
||||
regulation_short: string
|
||||
category: string
|
||||
article?: string
|
||||
source_url?: string
|
||||
score: number
|
||||
}
|
||||
|
||||
interface RAGSearchResponse {
|
||||
query: string
|
||||
results: RAGSearchResult[]
|
||||
count: number
|
||||
}
|
||||
|
||||
export function RAGSearchPanel({
|
||||
context,
|
||||
categories,
|
||||
onInsertText,
|
||||
}: {
|
||||
context: string
|
||||
categories?: string[]
|
||||
onInsertText?: (text: string) => void
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
const [results, setResults] = useState<RAGSearchResponse | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null)
|
||||
|
||||
const buildQuery = () => {
|
||||
if (query.trim()) return query.trim()
|
||||
return context.substring(0, 200)
|
||||
}
|
||||
|
||||
const handleSearch = async () => {
|
||||
const searchQuery = buildQuery()
|
||||
if (!searchQuery || searchQuery.length < 3) return
|
||||
|
||||
setIsSearching(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/sdk/v1/rag/search', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
query: searchQuery,
|
||||
collection: 'bp_dsfa_corpus',
|
||||
top_k: 5,
|
||||
}),
|
||||
})
|
||||
if (!response.ok) throw new Error(`Suche fehlgeschlagen (${response.status})`)
|
||||
const data: RAGSearchResponse = await response.json()
|
||||
setResults(data)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Suche fehlgeschlagen')
|
||||
setResults(null)
|
||||
} finally {
|
||||
setIsSearching(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleInsert = (text: string, resultId: string) => {
|
||||
if (onInsertText) {
|
||||
onInsertText(text)
|
||||
} else {
|
||||
navigator.clipboard.writeText(text)
|
||||
}
|
||||
setCopiedId(resultId)
|
||||
setTimeout(() => setCopiedId(null), 2000)
|
||||
}
|
||||
|
||||
if (!isOpen) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm bg-indigo-50 text-indigo-700 rounded-lg border border-indigo-200 hover:bg-indigo-100 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
Empfehlung suchen (RAG)
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-indigo-50 border border-indigo-200 rounded-xl p-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-medium text-indigo-800 flex items-center gap-2">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
DSFA-Wissenssuche (RAG)
|
||||
</h4>
|
||||
<button
|
||||
onClick={() => { setIsOpen(false); setResults(null); setError(null) }}
|
||||
className="text-indigo-400 hover:text-indigo-600"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search Input */}
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleSearch()}
|
||||
placeholder={`Suchbegriff (oder leer fuer automatische Kontextsuche)...`}
|
||||
className="flex-1 px-3 py-2 text-sm border border-indigo-300 rounded-lg bg-white focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
disabled={isSearching}
|
||||
className="px-4 py-2 text-sm bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{isSearching ? 'Suche...' : 'Suchen'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="text-sm text-red-600 bg-red-50 border border-red-200 rounded-lg p-3">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
{results && results.results.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs text-indigo-600">{results.count} Ergebnis(se) gefunden</p>
|
||||
|
||||
{results.results.map((r, idx) => {
|
||||
const resultId = `${r.regulation_code}-${idx}`
|
||||
return (
|
||||
<div key={resultId} className="bg-white rounded-lg border border-indigo-100 p-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs font-medium text-indigo-600 mb-1">
|
||||
{r.regulation_name}{r.article ? ` — ${r.article}` : ''}
|
||||
</div>
|
||||
<p className="text-sm text-gray-700 leading-relaxed whitespace-pre-line">
|
||||
{r.text.length > 400 ? r.text.substring(0, 400) + '...' : r.text}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<span className="text-xs text-gray-400 font-mono">
|
||||
{r.regulation_short || r.regulation_code} ({(r.score * 100).toFixed(0)}%)
|
||||
</span>
|
||||
{r.category && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-gray-100 text-gray-500">{r.category}</span>
|
||||
)}
|
||||
{r.source_url && (
|
||||
<a href={r.source_url} target="_blank" rel="noopener noreferrer" className="text-xs text-blue-500 hover:underline">Quelle</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleInsert(r.text, resultId)}
|
||||
className={`flex-shrink-0 px-3 py-1.5 text-xs rounded-lg transition-colors ${
|
||||
copiedId === resultId
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200'
|
||||
}`}
|
||||
title="In Beschreibung uebernehmen"
|
||||
>
|
||||
{copiedId === resultId ? 'Kopiert!' : 'Uebernehmen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{results && results.results.length === 0 && (
|
||||
<div className="text-sm text-indigo-600 text-center py-4">
|
||||
Keine Ergebnisse gefunden. Versuchen Sie einen anderen Suchbegriff.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { DSFA, SDM_GOALS } from '@/lib/sdk/dsfa/types'
|
||||
import type { SDMGoal } from '@/lib/sdk/dsfa/types'
|
||||
import { RISK_CATALOG } from '@/lib/sdk/dsfa/risk-catalog'
|
||||
import { MITIGATION_LIBRARY } from '@/lib/sdk/dsfa/mitigation-library'
|
||||
|
||||
export function SDMCoverageOverview({ dsfa }: { dsfa: DSFA }) {
|
||||
const goals = Object.keys(SDM_GOALS) as SDMGoal[]
|
||||
|
||||
const goalCoverage = goals.map(goal => {
|
||||
const catalogRisks = RISK_CATALOG.filter(r => r.sdmGoal === goal)
|
||||
const catalogMitigations = MITIGATION_LIBRARY.filter(m => m.sdmGoals.includes(goal))
|
||||
|
||||
const matchedRisks = catalogRisks.filter(cr =>
|
||||
dsfa.risks?.some(r => r.description?.includes(cr.title))
|
||||
).length
|
||||
|
||||
const matchedMitigations = catalogMitigations.filter(cm =>
|
||||
dsfa.mitigations?.some(m => m.description?.includes(cm.title))
|
||||
).length
|
||||
|
||||
const hasRisks = matchedRisks > 0 || dsfa.risks?.some(r => {
|
||||
const cat = r.category
|
||||
if (goal === 'vertraulichkeit' && cat === 'confidentiality') return true
|
||||
if (goal === 'integritaet' && cat === 'integrity') return true
|
||||
if (goal === 'verfuegbarkeit' && cat === 'availability') return true
|
||||
if (goal === 'nichtverkettung' && cat === 'rights_freedoms') return true
|
||||
return false
|
||||
})
|
||||
|
||||
const coverage = matchedMitigations > 0 ? 'covered' :
|
||||
hasRisks ? 'gaps' : 'no_data'
|
||||
|
||||
return {
|
||||
goal,
|
||||
info: SDM_GOALS[goal],
|
||||
matchedRisks,
|
||||
matchedMitigations,
|
||||
coverage,
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="mt-6 bg-white rounded-xl border p-6">
|
||||
<h3 className="text-md font-semibold text-gray-900 mb-1">SDM-Abdeckung (Gewaehrleistungsziele)</h3>
|
||||
<p className="text-xs text-gray-500 mb-4">Uebersicht ueber die Abdeckung der 7 Gewaehrleistungsziele des Standard-Datenschutzmodells.</p>
|
||||
|
||||
<div className="grid grid-cols-7 gap-2">
|
||||
{goalCoverage.map(({ goal, info, matchedRisks, matchedMitigations, coverage }) => (
|
||||
<div
|
||||
key={goal}
|
||||
className={`p-3 rounded-lg text-center border ${
|
||||
coverage === 'covered' ? 'bg-green-50 border-green-200' :
|
||||
coverage === 'gaps' ? 'bg-yellow-50 border-yellow-200' :
|
||||
'bg-gray-50 border-gray-200'
|
||||
}`}
|
||||
>
|
||||
<div className={`text-lg mb-1 ${
|
||||
coverage === 'covered' ? 'text-green-600' :
|
||||
coverage === 'gaps' ? 'text-yellow-600' :
|
||||
'text-gray-400'
|
||||
}`}>
|
||||
{coverage === 'covered' ? '\u2713' : coverage === 'gaps' ? '!' : '\u2013'}
|
||||
</div>
|
||||
<div className="text-xs font-medium text-gray-900 leading-tight">{info.name}</div>
|
||||
<div className="text-[10px] text-gray-500 mt-1">
|
||||
{matchedRisks}R / {matchedMitigations}M
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 mt-3 text-xs text-gray-500">
|
||||
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-green-500"></span> Abgedeckt</span>
|
||||
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-yellow-500"></span> Luecken</span>
|
||||
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-gray-300"></span> Keine Daten</span>
|
||||
<span className="ml-auto">R = Risiken, M = Massnahmen</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { DSFA, DSFA_LEGAL_BASES } from '@/lib/sdk/dsfa/types'
|
||||
|
||||
interface SectionProps {
|
||||
dsfa: DSFA
|
||||
onUpdate: (data: Record<string, unknown>) => Promise<void>
|
||||
isSubmitting: boolean
|
||||
}
|
||||
|
||||
export function Section1Editor({ dsfa, onUpdate, isSubmitting }: SectionProps) {
|
||||
const [formData, setFormData] = useState({
|
||||
processing_description: dsfa.processing_description || '',
|
||||
processing_purpose: dsfa.processing_purpose || '',
|
||||
data_categories: dsfa.data_categories || [],
|
||||
data_subjects: dsfa.data_subjects || [],
|
||||
recipients: dsfa.recipients || [],
|
||||
legal_basis: dsfa.legal_basis || '',
|
||||
legal_basis_details: dsfa.legal_basis_details || '',
|
||||
})
|
||||
const [newCategory, setNewCategory] = useState('')
|
||||
const [newSubject, setNewSubject] = useState('')
|
||||
const [newRecipient, setNewRecipient] = useState('')
|
||||
|
||||
const handleSave = () => {
|
||||
onUpdate(formData)
|
||||
}
|
||||
|
||||
const addItem = (field: 'data_categories' | 'data_subjects' | 'recipients', value: string, setter: (v: string) => void) => {
|
||||
if (value.trim()) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[field]: [...prev[field], value.trim()]
|
||||
}))
|
||||
setter('')
|
||||
}
|
||||
}
|
||||
|
||||
const removeItem = (field: 'data_categories' | 'data_subjects' | 'recipients', index: number) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[field]: prev[field].filter((_, i) => i !== index)
|
||||
}))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Processing Purpose */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Verarbeitungszweck *
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.processing_purpose}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, processing_purpose: e.target.value }))}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
rows={3}
|
||||
placeholder="Beschreiben Sie den Zweck der Datenverarbeitung..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Processing Description */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Beschreibung der Verarbeitung *
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.processing_description}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, processing_description: e.target.value }))}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
rows={4}
|
||||
placeholder="Detaillierte Beschreibung der Verarbeitungsvorgaenge..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Data Categories */}
|
||||
<TagListField
|
||||
label="Datenkategorien *"
|
||||
items={formData.data_categories}
|
||||
newValue={newCategory}
|
||||
onNewValueChange={setNewCategory}
|
||||
onAdd={() => addItem('data_categories', newCategory, setNewCategory)}
|
||||
onRemove={(idx) => removeItem('data_categories', idx)}
|
||||
placeholder="Kategorie hinzufuegen..."
|
||||
colorClass="bg-blue-100 text-blue-700"
|
||||
hoverClass="hover:text-blue-900"
|
||||
/>
|
||||
|
||||
{/* Data Subjects */}
|
||||
<TagListField
|
||||
label="Betroffene Personen *"
|
||||
items={formData.data_subjects}
|
||||
newValue={newSubject}
|
||||
onNewValueChange={setNewSubject}
|
||||
onAdd={() => addItem('data_subjects', newSubject, setNewSubject)}
|
||||
onRemove={(idx) => removeItem('data_subjects', idx)}
|
||||
placeholder="z.B. Kunden, Mitarbeiter..."
|
||||
colorClass="bg-green-100 text-green-700"
|
||||
hoverClass="hover:text-green-900"
|
||||
/>
|
||||
|
||||
{/* Recipients */}
|
||||
<TagListField
|
||||
label="Empfaenger"
|
||||
items={formData.recipients}
|
||||
newValue={newRecipient}
|
||||
onNewValueChange={setNewRecipient}
|
||||
onAdd={() => addItem('recipients', newRecipient, setNewRecipient)}
|
||||
onRemove={(idx) => removeItem('recipients', idx)}
|
||||
placeholder="Empfaenger hinzufuegen..."
|
||||
colorClass="bg-orange-100 text-orange-700"
|
||||
hoverClass="hover:text-orange-900"
|
||||
/>
|
||||
|
||||
{/* Legal Basis */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Rechtsgrundlage *
|
||||
</label>
|
||||
<select
|
||||
value={formData.legal_basis}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, legal_basis: e.target.value }))}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
>
|
||||
<option value="">Bitte waehlen...</option>
|
||||
{Object.entries(DSFA_LEGAL_BASES).map(([key, label]) => (
|
||||
<option key={key} value={key}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Legal Basis Details */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Begruendung der Rechtsgrundlage
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.legal_basis_details}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, legal_basis_details: e.target.value }))}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
rows={3}
|
||||
placeholder="Erlaeutern Sie, warum diese Rechtsgrundlage anwendbar ist..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="flex justify-end pt-4 border-t border-gray-200">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isSubmitting}
|
||||
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{isSubmitting ? 'Speichern...' : 'Abschnitt speichern'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Reusable tag-list input used for data_categories, data_subjects, recipients */
|
||||
function TagListField({
|
||||
label,
|
||||
items,
|
||||
newValue,
|
||||
onNewValueChange,
|
||||
onAdd,
|
||||
onRemove,
|
||||
placeholder,
|
||||
colorClass,
|
||||
hoverClass,
|
||||
}: {
|
||||
label: string
|
||||
items: string[]
|
||||
newValue: string
|
||||
onNewValueChange: (v: string) => void
|
||||
onAdd: () => void
|
||||
onRemove: (idx: number) => void
|
||||
placeholder: string
|
||||
colorClass: string
|
||||
hoverClass: string
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{label}</label>
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{items.map((item, idx) => (
|
||||
<span key={idx} className={`px-3 py-1 ${colorClass} rounded-full text-sm flex items-center gap-2`}>
|
||||
{item}
|
||||
<button onClick={() => onRemove(idx)} className={hoverClass}>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newValue}
|
||||
onChange={(e) => onNewValueChange(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && onAdd()}
|
||||
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
<button
|
||||
onClick={onAdd}
|
||||
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { DSFA } from '@/lib/sdk/dsfa/types'
|
||||
|
||||
interface SectionProps {
|
||||
dsfa: DSFA
|
||||
onUpdate: (data: Record<string, unknown>) => Promise<void>
|
||||
isSubmitting: boolean
|
||||
}
|
||||
|
||||
export function Section2Editor({ dsfa, onUpdate, isSubmitting }: SectionProps) {
|
||||
const [formData, setFormData] = useState({
|
||||
necessity_assessment: dsfa.necessity_assessment || '',
|
||||
proportionality_assessment: dsfa.proportionality_assessment || '',
|
||||
data_minimization: dsfa.data_minimization || '',
|
||||
alternatives_considered: dsfa.alternatives_considered || '',
|
||||
retention_justification: dsfa.retention_justification || '',
|
||||
})
|
||||
|
||||
const handleSave = () => {
|
||||
onUpdate(formData)
|
||||
}
|
||||
|
||||
const fields = [
|
||||
{
|
||||
key: 'necessity_assessment',
|
||||
label: 'Notwendigkeit der Verarbeitung *',
|
||||
hint: 'Erklaeren Sie, warum die Verarbeitung fuer den angegebenen Zweck notwendig ist.',
|
||||
placeholder: 'Beschreiben Sie die Notwendigkeit...',
|
||||
rows: 4,
|
||||
},
|
||||
{
|
||||
key: 'proportionality_assessment',
|
||||
label: 'Verhaeltnismaessigkeit *',
|
||||
hint: 'Begruenden Sie, warum der Eingriff in die Rechte der Betroffenen verhaeltnismaessig ist.',
|
||||
placeholder: 'Beschreiben Sie die Verhaeltnismaessigkeit...',
|
||||
rows: 4,
|
||||
},
|
||||
{
|
||||
key: 'data_minimization',
|
||||
label: 'Datenminimierung',
|
||||
hint: 'Welche Massnahmen wurden ergriffen, um nur die notwendigen Daten zu erheben?',
|
||||
placeholder: 'Beschreiben Sie die Datenminimierungsmassnahmen...',
|
||||
rows: 3,
|
||||
},
|
||||
{
|
||||
key: 'alternatives_considered',
|
||||
label: 'Gepruefe Alternativen',
|
||||
hint: 'Welche weniger eingriffsintensiven Alternativen wurden geprueft?',
|
||||
placeholder: 'Beschreiben Sie gepruefe Alternativen...',
|
||||
rows: 3,
|
||||
},
|
||||
{
|
||||
key: 'retention_justification',
|
||||
label: 'Speicherdauer / Loeschfristen',
|
||||
hint: undefined,
|
||||
placeholder: 'Begruenden Sie die geplante Speicherdauer...',
|
||||
rows: 3,
|
||||
},
|
||||
] as const
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{fields.map(({ key, label, hint, placeholder, rows }) => (
|
||||
<div key={key}>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{label}</label>
|
||||
{hint && <p className="text-xs text-gray-500 mb-2">{hint}</p>}
|
||||
<textarea
|
||||
value={formData[key]}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, [key]: e.target.value }))}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
rows={rows}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="flex justify-end pt-4 border-t border-gray-200">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isSubmitting}
|
||||
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{isSubmitting ? 'Speichern...' : 'Abschnitt speichern'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import {
|
||||
DSFA,
|
||||
DSFARisk,
|
||||
DSFA_RISK_LEVEL_LABELS,
|
||||
DSFA_AFFECTED_RIGHTS,
|
||||
calculateRiskLevel,
|
||||
} from '@/lib/sdk/dsfa/types'
|
||||
import { RiskMatrix } from '@/components/sdk/dsfa'
|
||||
import { RAGSearchPanel } from './RAGSearchPanel'
|
||||
import { AddRiskModal } from './AddRiskModal'
|
||||
|
||||
interface SectionProps {
|
||||
dsfa: DSFA
|
||||
onUpdate: (data: Record<string, unknown>) => Promise<void>
|
||||
isSubmitting: boolean
|
||||
}
|
||||
|
||||
export function Section3Editor({ dsfa, onUpdate, isSubmitting }: SectionProps) {
|
||||
const [selectedRisk, setSelectedRisk] = useState<DSFARisk | null>(null)
|
||||
const [showAddRiskModal, setShowAddRiskModal] = useState(false)
|
||||
const [newRiskLikelihood, setNewRiskLikelihood] = useState<'low' | 'medium' | 'high'>('medium')
|
||||
const [newRiskImpact, setNewRiskImpact] = useState<'low' | 'medium' | 'high'>('medium')
|
||||
const [affectedRights, setAffectedRights] = useState<string[]>(dsfa.affected_rights || [])
|
||||
|
||||
const handleAddRisk = (likelihood: 'low' | 'medium' | 'high', impact: 'low' | 'medium' | 'high') => {
|
||||
setNewRiskLikelihood(likelihood)
|
||||
setNewRiskImpact(impact)
|
||||
setShowAddRiskModal(true)
|
||||
}
|
||||
|
||||
const toggleAffectedRight = (rightId: string) => {
|
||||
setAffectedRights(prev =>
|
||||
prev.includes(rightId)
|
||||
? prev.filter(r => r !== rightId)
|
||||
: [...prev, rightId]
|
||||
)
|
||||
}
|
||||
|
||||
const handleSaveSection = () => {
|
||||
onUpdate({
|
||||
affected_rights: affectedRights,
|
||||
overall_risk_level: dsfa.overall_risk_level,
|
||||
})
|
||||
}
|
||||
|
||||
const levelColors = {
|
||||
low: 'bg-green-100 text-green-700 border-green-200',
|
||||
medium: 'bg-yellow-100 text-yellow-700 border-yellow-200',
|
||||
high: 'bg-orange-100 text-orange-700 border-orange-200',
|
||||
very_high: 'bg-red-100 text-red-700 border-red-200',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Risk Matrix */}
|
||||
<div className="bg-gray-50 rounded-xl p-6">
|
||||
<RiskMatrix
|
||||
risks={dsfa.risks || []}
|
||||
onRiskSelect={(risk) => setSelectedRisk(risk)}
|
||||
onAddRisk={handleAddRisk}
|
||||
selectedRiskId={selectedRisk?.id}
|
||||
readOnly={dsfa.status !== 'draft' && dsfa.status !== 'needs_update'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Triggered Rules from UCCA */}
|
||||
{dsfa.triggered_rule_codes && dsfa.triggered_rule_codes.length > 0 && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl p-4">
|
||||
<h4 className="text-sm font-medium text-red-800 mb-2">
|
||||
DSFA-ausloesende Regeln (aus UCCA)
|
||||
</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{dsfa.triggered_rule_codes.map(code => (
|
||||
<span key={code} className="px-2 py-1 bg-red-100 text-red-700 rounded text-xs">
|
||||
{code}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Risk List */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-3">Identifizierte Risiken ({(dsfa.risks || []).length})</h4>
|
||||
{(dsfa.risks || []).length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<svg className="w-12 h-12 mx-auto text-gray-300 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<p>Noch keine Risiken erfasst</p>
|
||||
<p className="text-sm mt-1">Klicken Sie in der Matrix auf eine Zelle, um ein Risiko hinzuzufuegen.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{(dsfa.risks || []).map(risk => {
|
||||
const { level } = calculateRiskLevel(risk.likelihood, risk.impact)
|
||||
return (
|
||||
<div
|
||||
key={risk.id}
|
||||
className={`p-4 rounded-xl border cursor-pointer transition-all ${
|
||||
selectedRisk?.id === risk.id ? 'ring-2 ring-purple-500' : ''
|
||||
} ${levelColors[level]}`}
|
||||
onClick={() => setSelectedRisk(risk)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{risk.description}</div>
|
||||
<div className="text-sm mt-1 opacity-75">
|
||||
Kategorie: {risk.category === 'confidentiality' ? 'Vertraulichkeit' :
|
||||
risk.category === 'integrity' ? 'Integritaet' :
|
||||
risk.category === 'availability' ? 'Verfuegbarkeit' :
|
||||
'Rechte & Freiheiten'}
|
||||
</div>
|
||||
<div className="text-sm opacity-75">
|
||||
Eintrittswahrscheinlichkeit: {risk.likelihood === 'low' ? 'Niedrig' : risk.likelihood === 'medium' ? 'Mittel' : 'Hoch'} |
|
||||
Auswirkung: {risk.impact === 'low' ? 'Niedrig' : risk.impact === 'medium' ? 'Mittel' : 'Hoch'}
|
||||
</div>
|
||||
</div>
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${levelColors[level]}`}>
|
||||
{DSFA_RISK_LEVEL_LABELS[level]}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* RAG Search for Risks */}
|
||||
<RAGSearchPanel
|
||||
context={`Risiken Datenschutz-Folgenabschaetzung ${dsfa.processing_description || ''} ${dsfa.processing_purpose || ''}`}
|
||||
categories={['risk_assessment', 'threshold_analysis']}
|
||||
/>
|
||||
|
||||
{/* Affected Rights */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-3">Betroffene Rechte & Freiheiten</h4>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{DSFA_AFFECTED_RIGHTS.map(right => (
|
||||
<label
|
||||
key={right.id}
|
||||
className={`flex items-center gap-2 p-3 rounded-lg border cursor-pointer transition-colors ${
|
||||
affectedRights.includes(right.id)
|
||||
? 'bg-purple-50 border-purple-300'
|
||||
: 'bg-white border-gray-200 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={affectedRights.includes(right.id)}
|
||||
onChange={() => toggleAffectedRight(right.id)}
|
||||
className="rounded border-gray-300 text-purple-600 focus:ring-purple-500"
|
||||
/>
|
||||
<span className="text-sm">{right.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="flex justify-end pt-4 border-t border-gray-200">
|
||||
<button
|
||||
onClick={handleSaveSection}
|
||||
disabled={isSubmitting}
|
||||
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{isSubmitting ? 'Speichern...' : 'Abschnitt speichern'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Add Risk Modal */}
|
||||
{showAddRiskModal && (
|
||||
<AddRiskModal
|
||||
likelihood={newRiskLikelihood}
|
||||
impact={newRiskImpact}
|
||||
onClose={() => setShowAddRiskModal(false)}
|
||||
onAdd={async (riskData) => {
|
||||
// This would call the API
|
||||
setShowAddRiskModal(false)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { DSFA, DSFAMitigation } from '@/lib/sdk/dsfa/types'
|
||||
import { RAGSearchPanel } from './RAGSearchPanel'
|
||||
import { AddMitigationModal } from './AddMitigationModal'
|
||||
|
||||
interface SectionProps {
|
||||
dsfa: DSFA
|
||||
onUpdate: (data: Record<string, unknown>) => Promise<void>
|
||||
isSubmitting: boolean
|
||||
}
|
||||
|
||||
export function Section4Editor({ dsfa, onUpdate, isSubmitting }: SectionProps) {
|
||||
const [showAddMitigation, setShowAddMitigation] = useState(false)
|
||||
|
||||
const mitigationsByType = {
|
||||
technical: (dsfa.mitigations || []).filter(m => m.type === 'technical'),
|
||||
organizational: (dsfa.mitigations || []).filter(m => m.type === 'organizational'),
|
||||
legal: (dsfa.mitigations || []).filter(m => m.type === 'legal'),
|
||||
}
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const styles = {
|
||||
planned: 'bg-gray-100 text-gray-600',
|
||||
in_progress: 'bg-yellow-100 text-yellow-700',
|
||||
implemented: 'bg-blue-100 text-blue-700',
|
||||
verified: 'bg-green-100 text-green-700',
|
||||
}
|
||||
const labels = {
|
||||
planned: 'Geplant',
|
||||
in_progress: 'In Umsetzung',
|
||||
implemented: 'Umgesetzt',
|
||||
verified: 'Verifiziert',
|
||||
}
|
||||
return (
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${styles[status as keyof typeof styles] || styles.planned}`}>
|
||||
{labels[status as keyof typeof labels] || status}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const renderMitigationSection = (
|
||||
title: string,
|
||||
icon: React.ReactNode,
|
||||
mitigations: DSFAMitigation[],
|
||||
bgColor: string
|
||||
) => (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
{icon}
|
||||
<h4 className="text-sm font-medium text-gray-700">{title} ({mitigations.length})</h4>
|
||||
</div>
|
||||
{mitigations.length === 0 ? (
|
||||
<div className={`p-4 rounded-lg ${bgColor} text-center text-sm text-gray-500`}>
|
||||
Keine Massnahmen definiert
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{mitigations.map(m => (
|
||||
<div key={m.id} className={`p-4 rounded-lg ${bgColor} border border-gray-200`}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-gray-900">{m.description}</div>
|
||||
<div className="text-sm text-gray-500 mt-1">
|
||||
Verantwortlich: {m.responsible_party || '-'}
|
||||
{m.tom_reference && ` | TOM: ${m.tom_reference}`}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{getStatusBadge(m.status)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Add Mitigation Button */}
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={() => setShowAddMitigation(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Massnahme hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Technical Measures */}
|
||||
{renderMitigationSection(
|
||||
'Technische Massnahmen',
|
||||
<svg className="w-5 h-5 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
|
||||
</svg>,
|
||||
mitigationsByType.technical,
|
||||
'bg-blue-50'
|
||||
)}
|
||||
|
||||
{/* Organizational Measures */}
|
||||
{renderMitigationSection(
|
||||
'Organisatorische Massnahmen',
|
||||
<svg className="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
</svg>,
|
||||
mitigationsByType.organizational,
|
||||
'bg-green-50'
|
||||
)}
|
||||
|
||||
{/* Legal Measures */}
|
||||
{renderMitigationSection(
|
||||
'Rechtliche Massnahmen',
|
||||
<svg className="w-5 h-5 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 6l3 1m0 0l-3 9a5.002 5.002 0 006.001 0M6 7l3 9M6 7l6-2m6 2l3-1m-3 1l-3 9a5.002 5.002 0 006.001 0M18 7l3 9m-3-9l-6-2m0-2v2m0 16V5m0 16H9m3 0h3" />
|
||||
</svg>,
|
||||
mitigationsByType.legal,
|
||||
'bg-purple-50'
|
||||
)}
|
||||
|
||||
{/* RAG Search for Mitigations */}
|
||||
<RAGSearchPanel
|
||||
context={`Massnahmen Datenschutz-Folgenabschaetzung ${dsfa.processing_description || ''} ${dsfa.processing_purpose || ''}`}
|
||||
categories={['mitigation', 'risk_assessment']}
|
||||
/>
|
||||
|
||||
{/* TOM References */}
|
||||
{dsfa.tom_references && dsfa.tom_references.length > 0 && (
|
||||
<div className="bg-gray-50 rounded-xl p-4">
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-2">Verknuepfte TOM-Eintraege</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{dsfa.tom_references.map((ref, idx) => (
|
||||
<span key={idx} className="px-3 py-1 bg-white border border-gray-200 rounded-full text-sm text-gray-600">
|
||||
{ref}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Mitigation Modal */}
|
||||
{showAddMitigation && (
|
||||
<AddMitigationModal
|
||||
risks={dsfa.risks || []}
|
||||
onClose={() => setShowAddMitigation(false)}
|
||||
onAdd={async (mitigationData) => {
|
||||
// This would call the API
|
||||
setShowAddMitigation(false)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { DSFA } from '@/lib/sdk/dsfa/types'
|
||||
|
||||
interface SectionProps {
|
||||
dsfa: DSFA
|
||||
onUpdate: (data: Record<string, unknown>) => Promise<void>
|
||||
isSubmitting: boolean
|
||||
}
|
||||
|
||||
export function Section5Editor({ dsfa, onUpdate, isSubmitting }: SectionProps) {
|
||||
const [formData, setFormData] = useState({
|
||||
dpo_consulted: dsfa.dpo_consulted || false,
|
||||
dpo_name: dsfa.dpo_name || '',
|
||||
dpo_opinion: dsfa.dpo_opinion || '',
|
||||
authority_consulted: dsfa.authority_consulted || false,
|
||||
authority_reference: dsfa.authority_reference || '',
|
||||
authority_decision: dsfa.authority_decision || '',
|
||||
})
|
||||
|
||||
const handleSave = () => {
|
||||
onUpdate(formData)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* DPO Consultation */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-12 h-12 bg-blue-100 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.dpo_consulted}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, dpo_consulted: e.target.checked }))}
|
||||
className="w-5 h-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="font-medium text-blue-800">
|
||||
Datenschutzbeauftragter wurde konsultiert
|
||||
</span>
|
||||
</label>
|
||||
<p className="text-sm text-blue-600 mt-2">
|
||||
Gemaess Art. 35 Abs. 2 DSGVO muss bei einer DSFA der Rat des DSB eingeholt werden.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{formData.dpo_consulted && (
|
||||
<div className="mt-4 space-y-4 pt-4 border-t border-blue-200">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-blue-800 mb-2">Name des DSB</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.dpo_name}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, dpo_name: e.target.value }))}
|
||||
className="w-full px-4 py-2 border border-blue-300 rounded-lg focus:ring-2 focus:ring-blue-500 bg-white"
|
||||
placeholder="Name des Datenschutzbeauftragten"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-blue-800 mb-2">Stellungnahme des DSB</label>
|
||||
<textarea
|
||||
value={formData.dpo_opinion}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, dpo_opinion: e.target.value }))}
|
||||
className="w-full px-4 py-3 border border-blue-300 rounded-lg focus:ring-2 focus:ring-blue-500 bg-white"
|
||||
rows={4}
|
||||
placeholder="Stellungnahme und Empfehlungen des DSB..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Authority Consultation */}
|
||||
<div className="bg-orange-50 border border-orange-200 rounded-xl p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-12 h-12 bg-orange-100 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<svg className="w-6 h-6 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.authority_consulted}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, authority_consulted: e.target.checked }))}
|
||||
className="w-5 h-5 rounded border-gray-300 text-orange-600 focus:ring-orange-500"
|
||||
/>
|
||||
<span className="font-medium text-orange-800">
|
||||
Aufsichtsbehoerde wurde konsultiert (Art. 36)
|
||||
</span>
|
||||
</label>
|
||||
<p className="text-sm text-orange-600 mt-2">
|
||||
Falls nach Durchfuehrung der Massnahmen weiterhin ein hohes Risiko besteht, ist die Aufsichtsbehoerde zu konsultieren.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{formData.authority_consulted && (
|
||||
<div className="mt-4 space-y-4 pt-4 border-t border-orange-200">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-orange-800 mb-2">Aktenzeichen / Referenz</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.authority_reference}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, authority_reference: e.target.value }))}
|
||||
className="w-full px-4 py-2 border border-orange-300 rounded-lg focus:ring-2 focus:ring-orange-500 bg-white"
|
||||
placeholder="Aktenzeichen der Behoerde"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-orange-800 mb-2">Entscheidung der Behoerde</label>
|
||||
<textarea
|
||||
value={formData.authority_decision}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, authority_decision: e.target.value }))}
|
||||
className="w-full px-4 py-3 border border-orange-300 rounded-lg focus:ring-2 focus:ring-orange-500 bg-white"
|
||||
rows={4}
|
||||
placeholder="Entscheidung und Auflagen der Aufsichtsbehoerde..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-xl p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<svg className="w-5 h-5 text-gray-400 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div className="text-sm text-gray-600">
|
||||
<p className="font-medium text-gray-700 mb-1">Hinweis zur Konsultation</p>
|
||||
<p>
|
||||
Die Konsultation der Aufsichtsbehoerde ist nur erforderlich, wenn nach Umsetzung der
|
||||
geplanten Massnahmen weiterhin ein hohes Risiko fuer die Rechte und Freiheiten der
|
||||
betroffenen Personen besteht.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="flex justify-end pt-4 border-t border-gray-200">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isSubmitting}
|
||||
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{isSubmitting ? 'Speichern...' : 'Abschnitt speichern'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
9
admin-compliance/app/sdk/dsfa/[id]/_components/index.ts
Normal file
9
admin-compliance/app/sdk/dsfa/[id]/_components/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export { Section1Editor } from './Section1Editor'
|
||||
export { Section2Editor } from './Section2Editor'
|
||||
export { Section3Editor } from './Section3Editor'
|
||||
export { Section4Editor } from './Section4Editor'
|
||||
export { Section5Editor } from './Section5Editor'
|
||||
export { SDMCoverageOverview } from './SDMCoverageOverview'
|
||||
export { RAGSearchPanel } from './RAGSearchPanel'
|
||||
export { AddRiskModal } from './AddRiskModal'
|
||||
export { AddMitigationModal } from './AddMitigationModal'
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,75 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import {
|
||||
Incident,
|
||||
getHoursUntil72hDeadline,
|
||||
is72hDeadlineExpired
|
||||
} from '@/lib/sdk/incidents/types'
|
||||
|
||||
/**
|
||||
* 72h-Countdown-Anzeige mit visueller Farbkodierung
|
||||
* Gruen > 48h, Gelb > 24h, Orange > 12h, Rot < 12h oder abgelaufen
|
||||
*/
|
||||
export function CountdownTimer({ incident }: { incident: Incident }) {
|
||||
const hoursRemaining = getHoursUntil72hDeadline(incident.detectedAt)
|
||||
const expired = is72hDeadlineExpired(incident.detectedAt)
|
||||
|
||||
// Nicht relevant fuer abgeschlossene Vorfaelle
|
||||
if (incident.status === 'closed') return null
|
||||
|
||||
// Bereits gemeldet
|
||||
if (incident.authorityNotification && (incident.authorityNotification.status === 'submitted' || incident.authorityNotification.status === 'acknowledged')) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium bg-green-100 text-green-700 rounded-full">
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Gemeldet
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Keine Meldepflicht festgestellt
|
||||
if (incident.riskAssessment && !incident.riskAssessment.notificationRequired) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium bg-gray-100 text-gray-600 rounded-full">
|
||||
Keine Meldepflicht
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Abgelaufen
|
||||
if (expired) {
|
||||
const overdueHours = Math.abs(hoursRemaining)
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-bold bg-red-100 text-red-700 rounded-full animate-pulse">
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{overdueHours.toFixed(0)}h ueberfaellig
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Farbkodierung: gruen > 48h, gelb > 24h, orange > 12h, rot < 12h
|
||||
let colorClass: string
|
||||
if (hoursRemaining > 48) {
|
||||
colorClass = 'bg-green-100 text-green-700'
|
||||
} else if (hoursRemaining > 24) {
|
||||
colorClass = 'bg-yellow-100 text-yellow-700'
|
||||
} else if (hoursRemaining > 12) {
|
||||
colorClass = 'bg-orange-100 text-orange-700'
|
||||
} else {
|
||||
colorClass = 'bg-red-100 text-red-700'
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-1 text-xs font-bold rounded-full ${colorClass}`}>
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{hoursRemaining.toFixed(0)}h verbleibend
|
||||
</span>
|
||||
)
|
||||
}
|
||||
83
admin-compliance/app/sdk/incidents/_components/FilterBar.tsx
Normal file
83
admin-compliance/app/sdk/incidents/_components/FilterBar.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import {
|
||||
IncidentSeverity,
|
||||
IncidentStatus,
|
||||
IncidentCategory,
|
||||
INCIDENT_SEVERITY_INFO,
|
||||
INCIDENT_STATUS_INFO,
|
||||
INCIDENT_CATEGORY_INFO
|
||||
} from '@/lib/sdk/incidents/types'
|
||||
|
||||
export function FilterBar({
|
||||
selectedSeverity,
|
||||
selectedStatus,
|
||||
selectedCategory,
|
||||
onSeverityChange,
|
||||
onStatusChange,
|
||||
onCategoryChange,
|
||||
onClear
|
||||
}: {
|
||||
selectedSeverity: IncidentSeverity | 'all'
|
||||
selectedStatus: IncidentStatus | 'all'
|
||||
selectedCategory: IncidentCategory | 'all'
|
||||
onSeverityChange: (severity: IncidentSeverity | 'all') => void
|
||||
onStatusChange: (status: IncidentStatus | 'all') => void
|
||||
onCategoryChange: (category: IncidentCategory | 'all') => void
|
||||
onClear: () => void
|
||||
}) {
|
||||
const hasFilters = selectedSeverity !== 'all' || selectedStatus !== 'all' || selectedCategory !== 'all'
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<span className="text-sm text-gray-500">Filter:</span>
|
||||
|
||||
{/* Severity Filter */}
|
||||
<select
|
||||
value={selectedSeverity}
|
||||
onChange={(e) => onSeverityChange(e.target.value as IncidentSeverity | 'all')}
|
||||
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
>
|
||||
<option value="all">Alle Schweregrade</option>
|
||||
{Object.entries(INCIDENT_SEVERITY_INFO).map(([severity, info]) => (
|
||||
<option key={severity} value={severity}>{info.label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Status Filter */}
|
||||
<select
|
||||
value={selectedStatus}
|
||||
onChange={(e) => onStatusChange(e.target.value as IncidentStatus | 'all')}
|
||||
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
>
|
||||
<option value="all">Alle Status</option>
|
||||
{Object.entries(INCIDENT_STATUS_INFO).map(([status, info]) => (
|
||||
<option key={status} value={status}>{info.label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Category Filter */}
|
||||
<select
|
||||
value={selectedCategory}
|
||||
onChange={(e) => onCategoryChange(e.target.value as IncidentCategory | 'all')}
|
||||
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
>
|
||||
<option value="all">Alle Kategorien</option>
|
||||
{Object.entries(INCIDENT_CATEGORY_INFO).map(([cat, info]) => (
|
||||
<option key={cat} value={cat}>{info.label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Clear Filters */}
|
||||
{hasFilters && (
|
||||
<button
|
||||
onClick={onClear}
|
||||
className="px-3 py-1.5 text-sm text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
Filter zuruecksetzen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
125
admin-compliance/app/sdk/incidents/_components/IncidentCard.tsx
Normal file
125
admin-compliance/app/sdk/incidents/_components/IncidentCard.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import {
|
||||
Incident,
|
||||
IncidentSeverity,
|
||||
INCIDENT_SEVERITY_INFO,
|
||||
INCIDENT_STATUS_INFO,
|
||||
INCIDENT_CATEGORY_INFO,
|
||||
is72hDeadlineExpired
|
||||
} from '@/lib/sdk/incidents/types'
|
||||
import { CountdownTimer } from './CountdownTimer'
|
||||
|
||||
function Badge({ bgColor, color, label }: { bgColor: string; color: string; label: string }) {
|
||||
return <span className={`px-2 py-1 text-xs rounded-full ${bgColor} ${color}`}>{label}</span>
|
||||
}
|
||||
|
||||
export function IncidentCard({ incident, onClick }: { incident: Incident; onClick?: () => void }) {
|
||||
const severityInfo = INCIDENT_SEVERITY_INFO[incident.severity]
|
||||
const statusInfo = INCIDENT_STATUS_INFO[incident.status]
|
||||
const categoryInfo = INCIDENT_CATEGORY_INFO[incident.category]
|
||||
|
||||
const expired = is72hDeadlineExpired(incident.detectedAt)
|
||||
const isNotified = incident.authorityNotification && (incident.authorityNotification.status === 'submitted' || incident.authorityNotification.status === 'acknowledged')
|
||||
|
||||
const severityBorderColors: Record<IncidentSeverity, string> = {
|
||||
critical: 'border-red-300 hover:border-red-400',
|
||||
high: 'border-orange-300 hover:border-orange-400',
|
||||
medium: 'border-yellow-300 hover:border-yellow-400',
|
||||
low: 'border-green-200 hover:border-green-300'
|
||||
}
|
||||
|
||||
const borderColor = incident.status === 'closed'
|
||||
? 'border-green-200 hover:border-green-300'
|
||||
: expired && !isNotified
|
||||
? 'border-red-400 hover:border-red-500'
|
||||
: severityBorderColors[incident.severity]
|
||||
|
||||
const measuresCount = incident.measures.length
|
||||
const completedMeasures = incident.measures.filter(m => m.status === 'completed').length
|
||||
|
||||
return (
|
||||
<div onClick={onClick} className="cursor-pointer">
|
||||
<div className={`
|
||||
bg-white rounded-xl border-2 p-6 hover:shadow-md transition-all cursor-pointer
|
||||
${borderColor}
|
||||
`}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Header Badges */}
|
||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||
<span className="text-xs text-gray-500 font-mono">
|
||||
{incident.referenceNumber}
|
||||
</span>
|
||||
<Badge bgColor={severityInfo.bgColor} color={severityInfo.color} label={severityInfo.label} />
|
||||
<Badge bgColor={categoryInfo.bgColor} color={categoryInfo.color} label={`${categoryInfo.icon} ${categoryInfo.label}`} />
|
||||
<Badge bgColor={statusInfo.bgColor} color={statusInfo.color} label={statusInfo.label} />
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h3 className="text-lg font-semibold text-gray-900 truncate">
|
||||
{incident.title}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 mt-1 line-clamp-2">
|
||||
{incident.description}
|
||||
</p>
|
||||
|
||||
{/* 72h Countdown - prominent */}
|
||||
<div className="mt-3">
|
||||
<CountdownTimer incident={incident} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Side - Key Numbers */}
|
||||
<div className="text-right ml-4 flex-shrink-0">
|
||||
<div className="text-sm text-gray-500">
|
||||
Betroffene
|
||||
</div>
|
||||
<div className="text-xl font-bold text-gray-900">
|
||||
{incident.estimatedAffectedPersons.toLocaleString('de-DE')}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-1">
|
||||
{new Date(incident.detectedAt).toLocaleDateString('de-DE')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4 text-sm text-gray-500">
|
||||
<span className="flex items-center gap-1">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
|
||||
</svg>
|
||||
{completedMeasures}/{measuresCount} Massnahmen
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{incident.timeline.length} Eintraege
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-500">
|
||||
{incident.assignedTo
|
||||
? `Zugewiesen: ${incident.assignedTo}`
|
||||
: 'Nicht zugewiesen'
|
||||
}
|
||||
</span>
|
||||
{incident.status !== 'closed' ? (
|
||||
<span className="px-3 py-1 text-sm text-purple-600 hover:bg-purple-50 rounded-lg transition-colors">
|
||||
Bearbeiten
|
||||
</span>
|
||||
) : (
|
||||
<span className="px-3 py-1 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
Details
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
|
||||
export function IncidentCreateModal({
|
||||
onClose,
|
||||
onSuccess
|
||||
}: {
|
||||
onClose: () => void
|
||||
onSuccess: () => void
|
||||
}) {
|
||||
const [title, setTitle] = useState('')
|
||||
const [category, setCategory] = useState('data_breach')
|
||||
const [severity, setSeverity] = useState('medium')
|
||||
const [description, setDescription] = useState('')
|
||||
const [detectedBy, setDetectedBy] = useState('')
|
||||
const [affectedSystems, setAffectedSystems] = useState('')
|
||||
const [estimatedAffectedPersons, setEstimatedAffectedPersons] = useState('0')
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!title.trim()) {
|
||||
setError('Titel ist erforderlich.')
|
||||
return
|
||||
}
|
||||
setIsSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch('/api/sdk/v1/incidents', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
category,
|
||||
severity,
|
||||
description,
|
||||
detectedBy,
|
||||
affectedSystems: affectedSystems.split(',').map(s => s.trim()).filter(Boolean),
|
||||
estimatedAffectedPersons: Number(estimatedAffectedPersons)
|
||||
})
|
||||
})
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
throw new Error(data.detail || data.message || `Fehler: ${res.status}`)
|
||||
}
|
||||
onSuccess()
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50"
|
||||
onClick={onClose}
|
||||
/>
|
||||
{/* Modal */}
|
||||
<div className="relative bg-white rounded-xl shadow-2xl w-full max-w-lg mx-4 p-6 z-10 max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900">Neuen Vorfall erfassen</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 rounded transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Title */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Titel <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 text-sm"
|
||||
placeholder="Kurze Beschreibung des Vorfalls"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Kategorie</label>
|
||||
<select
|
||||
value={category}
|
||||
onChange={e => setCategory(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 text-sm"
|
||||
>
|
||||
<option value="data_breach">Datenpanne</option>
|
||||
<option value="unauthorized_access">Unbefugter Zugriff</option>
|
||||
<option value="data_loss">Datenverlust</option>
|
||||
<option value="system_compromise">Systemkompromittierung</option>
|
||||
<option value="phishing">Phishing</option>
|
||||
<option value="ransomware">Ransomware</option>
|
||||
<option value="insider_threat">Insider-Bedrohung</option>
|
||||
<option value="physical_breach">Physischer Vorfall</option>
|
||||
<option value="other">Sonstiges</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Severity */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Schweregrad</label>
|
||||
<select
|
||||
value={severity}
|
||||
onChange={e => setSeverity(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 text-sm"
|
||||
>
|
||||
<option value="low">Niedrig (1)</option>
|
||||
<option value="medium">Mittel (2)</option>
|
||||
<option value="high">Hoch (3)</option>
|
||||
<option value="critical">Kritisch (4)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 text-sm"
|
||||
placeholder="Detaillierte Beschreibung des Vorfalls"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Detected By */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Entdeckt von</label>
|
||||
<input
|
||||
type="text"
|
||||
value={detectedBy}
|
||||
onChange={e => setDetectedBy(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 text-sm"
|
||||
placeholder="Name / Team / System"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Affected Systems */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Betroffene Systeme
|
||||
<span className="ml-1 text-xs text-gray-400">(Kommagetrennt)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={affectedSystems}
|
||||
onChange={e => setAffectedSystems(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 text-sm"
|
||||
placeholder="z.B. CRM, E-Mail-Server, Datenbank"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Estimated Affected Persons */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Geschaetzte betroffene Personen</label>
|
||||
<input
|
||||
type="number"
|
||||
value={estimatedAffectedPersons}
|
||||
onChange={e => setEstimatedAffectedPersons(e.target.value)}
|
||||
min={0}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex items-center justify-end gap-3 mt-6 pt-4 border-t border-gray-200">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
{isSaving && (
|
||||
<svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
)}
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import {
|
||||
Incident,
|
||||
INCIDENT_SEVERITY_INFO,
|
||||
INCIDENT_STATUS_INFO,
|
||||
INCIDENT_CATEGORY_INFO
|
||||
} from '@/lib/sdk/incidents/types'
|
||||
import { CountdownTimer } from './CountdownTimer'
|
||||
|
||||
const STATUS_TRANSITIONS: Record<string, { label: string; nextStatus: string } | null> = {
|
||||
detected: { label: 'Bewertung starten', nextStatus: 'assessment' },
|
||||
assessment: { label: 'Eindaemmung starten', nextStatus: 'containment' },
|
||||
containment: { label: 'Meldepflicht pruefen', nextStatus: 'notification_required' },
|
||||
notification_required: { label: 'Gemeldet', nextStatus: 'notification_sent' },
|
||||
notification_sent: { label: 'Behebung starten', nextStatus: 'remediation' },
|
||||
remediation: { label: 'Abschliessen', nextStatus: 'closed' },
|
||||
closed: null
|
||||
}
|
||||
|
||||
export function IncidentDetailDrawer({
|
||||
incident,
|
||||
onClose,
|
||||
onStatusChange,
|
||||
onDeleted,
|
||||
}: {
|
||||
incident: Incident
|
||||
onClose: () => void
|
||||
onStatusChange: () => void
|
||||
onDeleted?: () => void
|
||||
}) {
|
||||
const [isChangingStatus, setIsChangingStatus] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
|
||||
const handleDeleteIncident = async () => {
|
||||
if (!window.confirm(`Incident "${incident.title}" wirklich löschen?`)) return
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/incidents/${incident.id}`, { method: 'DELETE' })
|
||||
if (!res.ok) throw new Error(`Fehler: ${res.status}`)
|
||||
onDeleted ? onDeleted() : onClose()
|
||||
} catch (err) {
|
||||
console.error('Löschen fehlgeschlagen:', err)
|
||||
alert('Löschen fehlgeschlagen.')
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}
|
||||
const severityInfo = INCIDENT_SEVERITY_INFO[incident.severity]
|
||||
const statusInfo = INCIDENT_STATUS_INFO[incident.status]
|
||||
const categoryInfo = INCIDENT_CATEGORY_INFO[incident.category]
|
||||
const transition = STATUS_TRANSITIONS[incident.status]
|
||||
|
||||
const handleStatusChange = async (newStatus: string) => {
|
||||
setIsChangingStatus(true)
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/incidents/${incident.id}/status`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status: newStatus })
|
||||
})
|
||||
if (!res.ok) {
|
||||
throw new Error(`Fehler: ${res.status}`)
|
||||
}
|
||||
onStatusChange()
|
||||
} catch (err) {
|
||||
console.error('Status-Aenderung fehlgeschlagen:', err)
|
||||
} finally {
|
||||
setIsChangingStatus(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/30"
|
||||
onClick={onClose}
|
||||
/>
|
||||
{/* Drawer */}
|
||||
<div className="fixed right-0 top-0 h-full w-[600px] bg-white shadow-2xl z-10 overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 sticky top-0 bg-white z-10">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${severityInfo.bgColor} ${severityInfo.color}`}>
|
||||
{severityInfo.label}
|
||||
</span>
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${statusInfo.bgColor} ${statusInfo.color}`}>
|
||||
{statusInfo.label}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 rounded transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Title */}
|
||||
<div>
|
||||
<p className="text-xs text-gray-400 font-mono mb-1">{incident.referenceNumber}</p>
|
||||
<h2 className="text-xl font-semibold text-gray-900">{incident.title}</h2>
|
||||
</div>
|
||||
|
||||
{/* Status Transition */}
|
||||
{transition && (
|
||||
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4">
|
||||
<p className="text-sm text-purple-700 mb-3">Naechster Schritt:</p>
|
||||
<button
|
||||
onClick={() => handleStatusChange(transition.nextStatus)}
|
||||
disabled={isChangingStatus}
|
||||
className="px-4 py-2 bg-purple-600 text-white text-sm rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
{isChangingStatus && (
|
||||
<svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
)}
|
||||
{transition.label}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Details Grid */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-1">Kategorie</p>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{categoryInfo.icon} {categoryInfo.label}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-1">Schweregrad</p>
|
||||
<p className="text-sm font-medium text-gray-900">{severityInfo.label}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-1">Status</p>
|
||||
<p className="text-sm font-medium text-gray-900">{statusInfo.label}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-1">Entdeckt am</p>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{new Date(incident.detectedAt).toLocaleString('de-DE')}
|
||||
</p>
|
||||
</div>
|
||||
{incident.detectedBy && (
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-1">Entdeckt von</p>
|
||||
<p className="text-sm font-medium text-gray-900">{incident.detectedBy}</p>
|
||||
</div>
|
||||
)}
|
||||
{incident.assignedTo && (
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-1">Zugewiesen an</p>
|
||||
<p className="text-sm font-medium text-gray-900">{incident.assignedTo}</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-1">Betroffene Personen (geschaetzt)</p>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{incident.estimatedAffectedPersons.toLocaleString('de-DE')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{incident.description && (
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-2">Beschreibung</p>
|
||||
<p className="text-sm text-gray-700 leading-relaxed bg-gray-50 rounded-lg p-3">
|
||||
{incident.description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Affected Systems */}
|
||||
{incident.affectedSystems && incident.affectedSystems.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-2">Betroffene Systeme</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{incident.affectedSystems.map((sys, idx) => (
|
||||
<span key={idx} className="px-2 py-1 text-xs bg-gray-100 text-gray-700 rounded-full">
|
||||
{sys}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 72h Countdown */}
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-2">72h-Meldefrist</p>
|
||||
<CountdownTimer incident={incident} />
|
||||
</div>
|
||||
|
||||
{/* Delete */}
|
||||
<div className="pt-2 border-t border-gray-100">
|
||||
<button
|
||||
onClick={handleDeleteIncident}
|
||||
disabled={isDeleting}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 text-sm transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isDeleting ? 'Löschen...' : 'Löschen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
52
admin-compliance/app/sdk/incidents/_components/StatCard.tsx
Normal file
52
admin-compliance/app/sdk/incidents/_components/StatCard.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
|
||||
export function StatCard({
|
||||
label,
|
||||
value,
|
||||
color = 'gray',
|
||||
icon,
|
||||
trend
|
||||
}: {
|
||||
label: string
|
||||
value: number | string
|
||||
color?: 'gray' | 'blue' | 'yellow' | 'red' | 'green' | 'purple' | 'orange'
|
||||
icon?: React.ReactNode
|
||||
trend?: { value: number; label: string }
|
||||
}) {
|
||||
const colorClasses: Record<string, string> = {
|
||||
gray: 'border-gray-200 text-gray-900',
|
||||
blue: 'border-blue-200 text-blue-600',
|
||||
yellow: 'border-yellow-200 text-yellow-600',
|
||||
red: 'border-red-200 text-red-600',
|
||||
green: 'border-green-200 text-green-600',
|
||||
purple: 'border-purple-200 text-purple-600',
|
||||
orange: 'border-orange-200 text-orange-600'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-xl border ${colorClasses[color]} p-6`}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className={`text-sm ${color === 'gray' ? 'text-gray-500' : ''}`}>
|
||||
{label}
|
||||
</div>
|
||||
<div className={`text-3xl font-bold mt-1 ${colorClasses[color].split(' ')[1]}`}>
|
||||
{value}
|
||||
</div>
|
||||
{trend && (
|
||||
<div className={`text-xs mt-1 ${trend.value >= 0 ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{trend.value >= 0 ? '+' : ''}{trend.value} {trend.label}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{icon && (
|
||||
<div className="w-10 h-10 rounded-lg flex items-center justify-center bg-gray-50">
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
|
||||
export type TabId = 'overview' | 'active' | 'notification' | 'closed' | 'settings'
|
||||
|
||||
export interface Tab {
|
||||
id: TabId
|
||||
label: string
|
||||
count?: number
|
||||
countColor?: string
|
||||
}
|
||||
|
||||
export function TabNavigation({
|
||||
tabs,
|
||||
activeTab,
|
||||
onTabChange
|
||||
}: {
|
||||
tabs: Tab[]
|
||||
activeTab: TabId
|
||||
onTabChange: (tab: TabId) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="border-b border-gray-200">
|
||||
<nav className="flex gap-1 -mb-px" aria-label="Tabs">
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => onTabChange(tab.id)}
|
||||
className={`
|
||||
px-4 py-3 text-sm font-medium border-b-2 transition-colors
|
||||
${activeTab === tab.id
|
||||
? 'border-purple-600 text-purple-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{tab.label}
|
||||
{tab.count !== undefined && tab.count > 0 && (
|
||||
<span className={`
|
||||
px-2 py-0.5 text-xs rounded-full
|
||||
${tab.countColor || 'bg-gray-100 text-gray-600'}
|
||||
`}>
|
||||
{tab.count}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -9,793 +9,16 @@ import {
|
||||
IncidentStatus,
|
||||
IncidentCategory,
|
||||
IncidentStatistics,
|
||||
INCIDENT_SEVERITY_INFO,
|
||||
INCIDENT_STATUS_INFO,
|
||||
INCIDENT_CATEGORY_INFO,
|
||||
getHoursUntil72hDeadline,
|
||||
is72hDeadlineExpired
|
||||
} from '@/lib/sdk/incidents/types'
|
||||
import { fetchSDKIncidentList, createMockIncidents, createMockStatistics } from '@/lib/sdk/incidents/api'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
type TabId = 'overview' | 'active' | 'notification' | 'closed' | 'settings'
|
||||
|
||||
interface Tab {
|
||||
id: TabId
|
||||
label: string
|
||||
count?: number
|
||||
countColor?: string
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// COMPONENTS
|
||||
// =============================================================================
|
||||
|
||||
function TabNavigation({
|
||||
tabs,
|
||||
activeTab,
|
||||
onTabChange
|
||||
}: {
|
||||
tabs: Tab[]
|
||||
activeTab: TabId
|
||||
onTabChange: (tab: TabId) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="border-b border-gray-200">
|
||||
<nav className="flex gap-1 -mb-px" aria-label="Tabs">
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => onTabChange(tab.id)}
|
||||
className={`
|
||||
px-4 py-3 text-sm font-medium border-b-2 transition-colors
|
||||
${activeTab === tab.id
|
||||
? 'border-purple-600 text-purple-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{tab.label}
|
||||
{tab.count !== undefined && tab.count > 0 && (
|
||||
<span className={`
|
||||
px-2 py-0.5 text-xs rounded-full
|
||||
${tab.countColor || 'bg-gray-100 text-gray-600'}
|
||||
`}>
|
||||
{tab.count}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
label,
|
||||
value,
|
||||
color = 'gray',
|
||||
icon,
|
||||
trend
|
||||
}: {
|
||||
label: string
|
||||
value: number | string
|
||||
color?: 'gray' | 'blue' | 'yellow' | 'red' | 'green' | 'purple' | 'orange'
|
||||
icon?: React.ReactNode
|
||||
trend?: { value: number; label: string }
|
||||
}) {
|
||||
const colorClasses: Record<string, string> = {
|
||||
gray: 'border-gray-200 text-gray-900',
|
||||
blue: 'border-blue-200 text-blue-600',
|
||||
yellow: 'border-yellow-200 text-yellow-600',
|
||||
red: 'border-red-200 text-red-600',
|
||||
green: 'border-green-200 text-green-600',
|
||||
purple: 'border-purple-200 text-purple-600',
|
||||
orange: 'border-orange-200 text-orange-600'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-xl border ${colorClasses[color]} p-6`}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className={`text-sm ${color === 'gray' ? 'text-gray-500' : ''}`}>
|
||||
{label}
|
||||
</div>
|
||||
<div className={`text-3xl font-bold mt-1 ${colorClasses[color].split(' ')[1]}`}>
|
||||
{value}
|
||||
</div>
|
||||
{trend && (
|
||||
<div className={`text-xs mt-1 ${trend.value >= 0 ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{trend.value >= 0 ? '+' : ''}{trend.value} {trend.label}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{icon && (
|
||||
<div className="w-10 h-10 rounded-lg flex items-center justify-center bg-gray-50">
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FilterBar({
|
||||
selectedSeverity,
|
||||
selectedStatus,
|
||||
selectedCategory,
|
||||
onSeverityChange,
|
||||
onStatusChange,
|
||||
onCategoryChange,
|
||||
onClear
|
||||
}: {
|
||||
selectedSeverity: IncidentSeverity | 'all'
|
||||
selectedStatus: IncidentStatus | 'all'
|
||||
selectedCategory: IncidentCategory | 'all'
|
||||
onSeverityChange: (severity: IncidentSeverity | 'all') => void
|
||||
onStatusChange: (status: IncidentStatus | 'all') => void
|
||||
onCategoryChange: (category: IncidentCategory | 'all') => void
|
||||
onClear: () => void
|
||||
}) {
|
||||
const hasFilters = selectedSeverity !== 'all' || selectedStatus !== 'all' || selectedCategory !== 'all'
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<span className="text-sm text-gray-500">Filter:</span>
|
||||
|
||||
{/* Severity Filter */}
|
||||
<select
|
||||
value={selectedSeverity}
|
||||
onChange={(e) => onSeverityChange(e.target.value as IncidentSeverity | 'all')}
|
||||
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
>
|
||||
<option value="all">Alle Schweregrade</option>
|
||||
{Object.entries(INCIDENT_SEVERITY_INFO).map(([severity, info]) => (
|
||||
<option key={severity} value={severity}>{info.label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Status Filter */}
|
||||
<select
|
||||
value={selectedStatus}
|
||||
onChange={(e) => onStatusChange(e.target.value as IncidentStatus | 'all')}
|
||||
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
>
|
||||
<option value="all">Alle Status</option>
|
||||
{Object.entries(INCIDENT_STATUS_INFO).map(([status, info]) => (
|
||||
<option key={status} value={status}>{info.label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Category Filter */}
|
||||
<select
|
||||
value={selectedCategory}
|
||||
onChange={(e) => onCategoryChange(e.target.value as IncidentCategory | 'all')}
|
||||
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
>
|
||||
<option value="all">Alle Kategorien</option>
|
||||
{Object.entries(INCIDENT_CATEGORY_INFO).map(([cat, info]) => (
|
||||
<option key={cat} value={cat}>{info.label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Clear Filters */}
|
||||
{hasFilters && (
|
||||
<button
|
||||
onClick={onClear}
|
||||
className="px-3 py-1.5 text-sm text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
Filter zuruecksetzen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 72h-Countdown-Anzeige mit visueller Farbkodierung
|
||||
* Gruen > 48h, Gelb > 24h, Orange > 12h, Rot < 12h oder abgelaufen
|
||||
*/
|
||||
function CountdownTimer({ incident }: { incident: Incident }) {
|
||||
const hoursRemaining = getHoursUntil72hDeadline(incident.detectedAt)
|
||||
const expired = is72hDeadlineExpired(incident.detectedAt)
|
||||
|
||||
// Nicht relevant fuer abgeschlossene Vorfaelle
|
||||
if (incident.status === 'closed') return null
|
||||
|
||||
// Bereits gemeldet
|
||||
if (incident.authorityNotification && (incident.authorityNotification.status === 'submitted' || incident.authorityNotification.status === 'acknowledged')) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium bg-green-100 text-green-700 rounded-full">
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Gemeldet
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Keine Meldepflicht festgestellt
|
||||
if (incident.riskAssessment && !incident.riskAssessment.notificationRequired) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium bg-gray-100 text-gray-600 rounded-full">
|
||||
Keine Meldepflicht
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Abgelaufen
|
||||
if (expired) {
|
||||
const overdueHours = Math.abs(hoursRemaining)
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-bold bg-red-100 text-red-700 rounded-full animate-pulse">
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{overdueHours.toFixed(0)}h ueberfaellig
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Farbkodierung: gruen > 48h, gelb > 24h, orange > 12h, rot < 12h
|
||||
let colorClass: string
|
||||
if (hoursRemaining > 48) {
|
||||
colorClass = 'bg-green-100 text-green-700'
|
||||
} else if (hoursRemaining > 24) {
|
||||
colorClass = 'bg-yellow-100 text-yellow-700'
|
||||
} else if (hoursRemaining > 12) {
|
||||
colorClass = 'bg-orange-100 text-orange-700'
|
||||
} else {
|
||||
colorClass = 'bg-red-100 text-red-700'
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-1 text-xs font-bold rounded-full ${colorClass}`}>
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{hoursRemaining.toFixed(0)}h verbleibend
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function Badge({ bgColor, color, label }: { bgColor: string; color: string; label: string }) {
|
||||
return <span className={`px-2 py-1 text-xs rounded-full ${bgColor} ${color}`}>{label}</span>
|
||||
}
|
||||
|
||||
function IncidentCard({ incident, onClick }: { incident: Incident; onClick?: () => void }) {
|
||||
const severityInfo = INCIDENT_SEVERITY_INFO[incident.severity]
|
||||
const statusInfo = INCIDENT_STATUS_INFO[incident.status]
|
||||
const categoryInfo = INCIDENT_CATEGORY_INFO[incident.category]
|
||||
|
||||
const expired = is72hDeadlineExpired(incident.detectedAt)
|
||||
const isNotified = incident.authorityNotification && (incident.authorityNotification.status === 'submitted' || incident.authorityNotification.status === 'acknowledged')
|
||||
|
||||
const severityBorderColors: Record<IncidentSeverity, string> = {
|
||||
critical: 'border-red-300 hover:border-red-400',
|
||||
high: 'border-orange-300 hover:border-orange-400',
|
||||
medium: 'border-yellow-300 hover:border-yellow-400',
|
||||
low: 'border-green-200 hover:border-green-300'
|
||||
}
|
||||
|
||||
const borderColor = incident.status === 'closed'
|
||||
? 'border-green-200 hover:border-green-300'
|
||||
: expired && !isNotified
|
||||
? 'border-red-400 hover:border-red-500'
|
||||
: severityBorderColors[incident.severity]
|
||||
|
||||
const measuresCount = incident.measures.length
|
||||
const completedMeasures = incident.measures.filter(m => m.status === 'completed').length
|
||||
|
||||
return (
|
||||
<div onClick={onClick} className="cursor-pointer">
|
||||
<div className={`
|
||||
bg-white rounded-xl border-2 p-6 hover:shadow-md transition-all cursor-pointer
|
||||
${borderColor}
|
||||
`}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Header Badges */}
|
||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||
<span className="text-xs text-gray-500 font-mono">
|
||||
{incident.referenceNumber}
|
||||
</span>
|
||||
<Badge bgColor={severityInfo.bgColor} color={severityInfo.color} label={severityInfo.label} />
|
||||
<Badge bgColor={categoryInfo.bgColor} color={categoryInfo.color} label={`${categoryInfo.icon} ${categoryInfo.label}`} />
|
||||
<Badge bgColor={statusInfo.bgColor} color={statusInfo.color} label={statusInfo.label} />
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h3 className="text-lg font-semibold text-gray-900 truncate">
|
||||
{incident.title}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 mt-1 line-clamp-2">
|
||||
{incident.description}
|
||||
</p>
|
||||
|
||||
{/* 72h Countdown - prominent */}
|
||||
<div className="mt-3">
|
||||
<CountdownTimer incident={incident} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Side - Key Numbers */}
|
||||
<div className="text-right ml-4 flex-shrink-0">
|
||||
<div className="text-sm text-gray-500">
|
||||
Betroffene
|
||||
</div>
|
||||
<div className="text-xl font-bold text-gray-900">
|
||||
{incident.estimatedAffectedPersons.toLocaleString('de-DE')}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-1">
|
||||
{new Date(incident.detectedAt).toLocaleDateString('de-DE')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4 text-sm text-gray-500">
|
||||
<span className="flex items-center gap-1">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
|
||||
</svg>
|
||||
{completedMeasures}/{measuresCount} Massnahmen
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{incident.timeline.length} Eintraege
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-500">
|
||||
{incident.assignedTo
|
||||
? `Zugewiesen: ${incident.assignedTo}`
|
||||
: 'Nicht zugewiesen'
|
||||
}
|
||||
</span>
|
||||
{incident.status !== 'closed' ? (
|
||||
<span className="px-3 py-1 text-sm text-purple-600 hover:bg-purple-50 rounded-lg transition-colors">
|
||||
Bearbeiten
|
||||
</span>
|
||||
) : (
|
||||
<span className="px-3 py-1 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
Details
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// INCIDENT CREATE MODAL
|
||||
// =============================================================================
|
||||
|
||||
function IncidentCreateModal({
|
||||
onClose,
|
||||
onSuccess
|
||||
}: {
|
||||
onClose: () => void
|
||||
onSuccess: () => void
|
||||
}) {
|
||||
const [title, setTitle] = useState('')
|
||||
const [category, setCategory] = useState('data_breach')
|
||||
const [severity, setSeverity] = useState('medium')
|
||||
const [description, setDescription] = useState('')
|
||||
const [detectedBy, setDetectedBy] = useState('')
|
||||
const [affectedSystems, setAffectedSystems] = useState('')
|
||||
const [estimatedAffectedPersons, setEstimatedAffectedPersons] = useState('0')
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!title.trim()) {
|
||||
setError('Titel ist erforderlich.')
|
||||
return
|
||||
}
|
||||
setIsSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch('/api/sdk/v1/incidents', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
category,
|
||||
severity,
|
||||
description,
|
||||
detectedBy,
|
||||
affectedSystems: affectedSystems.split(',').map(s => s.trim()).filter(Boolean),
|
||||
estimatedAffectedPersons: Number(estimatedAffectedPersons)
|
||||
})
|
||||
})
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
throw new Error(data.detail || data.message || `Fehler: ${res.status}`)
|
||||
}
|
||||
onSuccess()
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50"
|
||||
onClick={onClose}
|
||||
/>
|
||||
{/* Modal */}
|
||||
<div className="relative bg-white rounded-xl shadow-2xl w-full max-w-lg mx-4 p-6 z-10 max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900">Neuen Vorfall erfassen</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 rounded transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Title */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Titel <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 text-sm"
|
||||
placeholder="Kurze Beschreibung des Vorfalls"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Kategorie</label>
|
||||
<select
|
||||
value={category}
|
||||
onChange={e => setCategory(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 text-sm"
|
||||
>
|
||||
<option value="data_breach">Datenpanne</option>
|
||||
<option value="unauthorized_access">Unbefugter Zugriff</option>
|
||||
<option value="data_loss">Datenverlust</option>
|
||||
<option value="system_compromise">Systemkompromittierung</option>
|
||||
<option value="phishing">Phishing</option>
|
||||
<option value="ransomware">Ransomware</option>
|
||||
<option value="insider_threat">Insider-Bedrohung</option>
|
||||
<option value="physical_breach">Physischer Vorfall</option>
|
||||
<option value="other">Sonstiges</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Severity */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Schweregrad</label>
|
||||
<select
|
||||
value={severity}
|
||||
onChange={e => setSeverity(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 text-sm"
|
||||
>
|
||||
<option value="low">Niedrig (1)</option>
|
||||
<option value="medium">Mittel (2)</option>
|
||||
<option value="high">Hoch (3)</option>
|
||||
<option value="critical">Kritisch (4)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 text-sm"
|
||||
placeholder="Detaillierte Beschreibung des Vorfalls"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Detected By */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Entdeckt von</label>
|
||||
<input
|
||||
type="text"
|
||||
value={detectedBy}
|
||||
onChange={e => setDetectedBy(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 text-sm"
|
||||
placeholder="Name / Team / System"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Affected Systems */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Betroffene Systeme
|
||||
<span className="ml-1 text-xs text-gray-400">(Kommagetrennt)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={affectedSystems}
|
||||
onChange={e => setAffectedSystems(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 text-sm"
|
||||
placeholder="z.B. CRM, E-Mail-Server, Datenbank"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Estimated Affected Persons */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Geschaetzte betroffene Personen</label>
|
||||
<input
|
||||
type="number"
|
||||
value={estimatedAffectedPersons}
|
||||
onChange={e => setEstimatedAffectedPersons(e.target.value)}
|
||||
min={0}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex items-center justify-end gap-3 mt-6 pt-4 border-t border-gray-200">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
{isSaving && (
|
||||
<svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
)}
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// INCIDENT DETAIL DRAWER
|
||||
// =============================================================================
|
||||
|
||||
const STATUS_TRANSITIONS: Record<string, { label: string; nextStatus: string } | null> = {
|
||||
detected: { label: 'Bewertung starten', nextStatus: 'assessment' },
|
||||
assessment: { label: 'Eindaemmung starten', nextStatus: 'containment' },
|
||||
containment: { label: 'Meldepflicht pruefen', nextStatus: 'notification_required' },
|
||||
notification_required: { label: 'Gemeldet', nextStatus: 'notification_sent' },
|
||||
notification_sent: { label: 'Behebung starten', nextStatus: 'remediation' },
|
||||
remediation: { label: 'Abschliessen', nextStatus: 'closed' },
|
||||
closed: null
|
||||
}
|
||||
|
||||
function IncidentDetailDrawer({
|
||||
incident,
|
||||
onClose,
|
||||
onStatusChange,
|
||||
onDeleted,
|
||||
}: {
|
||||
incident: Incident
|
||||
onClose: () => void
|
||||
onStatusChange: () => void
|
||||
onDeleted?: () => void
|
||||
}) {
|
||||
const [isChangingStatus, setIsChangingStatus] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
|
||||
const handleDeleteIncident = async () => {
|
||||
if (!window.confirm(`Incident "${incident.title}" wirklich löschen?`)) return
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/incidents/${incident.id}`, { method: 'DELETE' })
|
||||
if (!res.ok) throw new Error(`Fehler: ${res.status}`)
|
||||
onDeleted ? onDeleted() : onClose()
|
||||
} catch (err) {
|
||||
console.error('Löschen fehlgeschlagen:', err)
|
||||
alert('Löschen fehlgeschlagen.')
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}
|
||||
const severityInfo = INCIDENT_SEVERITY_INFO[incident.severity]
|
||||
const statusInfo = INCIDENT_STATUS_INFO[incident.status]
|
||||
const categoryInfo = INCIDENT_CATEGORY_INFO[incident.category]
|
||||
const transition = STATUS_TRANSITIONS[incident.status]
|
||||
|
||||
const handleStatusChange = async (newStatus: string) => {
|
||||
setIsChangingStatus(true)
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/incidents/${incident.id}/status`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status: newStatus })
|
||||
})
|
||||
if (!res.ok) {
|
||||
throw new Error(`Fehler: ${res.status}`)
|
||||
}
|
||||
onStatusChange()
|
||||
} catch (err) {
|
||||
console.error('Status-Aenderung fehlgeschlagen:', err)
|
||||
} finally {
|
||||
setIsChangingStatus(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/30"
|
||||
onClick={onClose}
|
||||
/>
|
||||
{/* Drawer */}
|
||||
<div className="fixed right-0 top-0 h-full w-[600px] bg-white shadow-2xl z-10 overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 sticky top-0 bg-white z-10">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${severityInfo.bgColor} ${severityInfo.color}`}>
|
||||
{severityInfo.label}
|
||||
</span>
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${statusInfo.bgColor} ${statusInfo.color}`}>
|
||||
{statusInfo.label}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 rounded transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Title */}
|
||||
<div>
|
||||
<p className="text-xs text-gray-400 font-mono mb-1">{incident.referenceNumber}</p>
|
||||
<h2 className="text-xl font-semibold text-gray-900">{incident.title}</h2>
|
||||
</div>
|
||||
|
||||
{/* Status Transition */}
|
||||
{transition && (
|
||||
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4">
|
||||
<p className="text-sm text-purple-700 mb-3">Naechster Schritt:</p>
|
||||
<button
|
||||
onClick={() => handleStatusChange(transition.nextStatus)}
|
||||
disabled={isChangingStatus}
|
||||
className="px-4 py-2 bg-purple-600 text-white text-sm rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
{isChangingStatus && (
|
||||
<svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
)}
|
||||
{transition.label}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Details Grid */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-1">Kategorie</p>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{categoryInfo.icon} {categoryInfo.label}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-1">Schweregrad</p>
|
||||
<p className="text-sm font-medium text-gray-900">{severityInfo.label}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-1">Status</p>
|
||||
<p className="text-sm font-medium text-gray-900">{statusInfo.label}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-1">Entdeckt am</p>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{new Date(incident.detectedAt).toLocaleString('de-DE')}
|
||||
</p>
|
||||
</div>
|
||||
{incident.detectedBy && (
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-1">Entdeckt von</p>
|
||||
<p className="text-sm font-medium text-gray-900">{incident.detectedBy}</p>
|
||||
</div>
|
||||
)}
|
||||
{incident.assignedTo && (
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-1">Zugewiesen an</p>
|
||||
<p className="text-sm font-medium text-gray-900">{incident.assignedTo}</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-1">Betroffene Personen (geschaetzt)</p>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{incident.estimatedAffectedPersons.toLocaleString('de-DE')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{incident.description && (
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-2">Beschreibung</p>
|
||||
<p className="text-sm text-gray-700 leading-relaxed bg-gray-50 rounded-lg p-3">
|
||||
{incident.description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Affected Systems */}
|
||||
{incident.affectedSystems && incident.affectedSystems.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-2">Betroffene Systeme</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{incident.affectedSystems.map((sys, idx) => (
|
||||
<span key={idx} className="px-2 py-1 text-xs bg-gray-100 text-gray-700 rounded-full">
|
||||
{sys}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 72h Countdown */}
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-2">72h-Meldefrist</p>
|
||||
<CountdownTimer incident={incident} />
|
||||
</div>
|
||||
|
||||
{/* Delete */}
|
||||
<div className="pt-2 border-t border-gray-100">
|
||||
<button
|
||||
onClick={handleDeleteIncident}
|
||||
disabled={isDeleting}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 text-sm transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isDeleting ? 'Löschen...' : 'Löschen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import { TabNavigation, type Tab, type TabId } from './_components/TabNavigation'
|
||||
import { StatCard } from './_components/StatCard'
|
||||
import { FilterBar } from './_components/FilterBar'
|
||||
import { IncidentCard } from './_components/IncidentCard'
|
||||
import { IncidentCreateModal } from './_components/IncidentCreateModal'
|
||||
import { IncidentDetailDrawer } from './_components/IncidentDetailDrawer'
|
||||
|
||||
// =============================================================================
|
||||
// MAIN PAGE
|
||||
|
||||
152
admin-compliance/app/sdk/isms/_components/AuditsTab.tsx
Normal file
152
admin-compliance/app/sdk/isms/_components/AuditsTab.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { API, AuditFinding, CAPA, InternalAudit } from '../_types'
|
||||
import { EmptyState, LoadingSpinner, StatCard, StatusBadge } from './shared'
|
||||
|
||||
// =============================================================================
|
||||
// TAB: AUDITS (Internal Audits + Findings + CAPA)
|
||||
// =============================================================================
|
||||
|
||||
export function AuditsTab() {
|
||||
const [audits, setAudits] = useState<InternalAudit[]>([])
|
||||
const [findings, setFindings] = useState<AuditFinding[]>([])
|
||||
const [capas, setCAPAs] = useState<CAPA[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [subTab, setSubTab] = useState<'audits' | 'findings' | 'capa'>('audits')
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [aRes, fRes, cRes] = await Promise.all([
|
||||
fetch(`${API}/internal-audits`),
|
||||
fetch(`${API}/findings`),
|
||||
fetch(`${API}/capa`),
|
||||
])
|
||||
if (aRes.ok) { const d = await aRes.json(); setAudits(d.audits || []) }
|
||||
if (fRes.ok) { const d = await fRes.json(); setFindings(d.findings || []) }
|
||||
if (cRes.ok) { const d = await cRes.json(); setCAPAs(d.actions || []) }
|
||||
} catch { /* ignore */ }
|
||||
setLoading(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
if (loading) return <LoadingSpinner />
|
||||
|
||||
const openFindings = findings.filter(f => f.status !== 'closed')
|
||||
const majors = findings.filter(f => f.finding_type === 'major')
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<StatCard label="Interne Audits" value={audits.length} color="blue" />
|
||||
<StatCard label="Offene Findings" value={openFindings.length} color={openFindings.length > 0 ? 'red' : 'green'} />
|
||||
<StatCard label="Major Findings" value={majors.length} color={majors.length > 0 ? 'red' : 'green'} />
|
||||
<StatCard label="CAPAs" value={capas.length} color="purple" />
|
||||
</div>
|
||||
|
||||
{/* Sub-tabs */}
|
||||
<div className="flex gap-2 border-b">
|
||||
{(['audits', 'findings', 'capa'] as const).map(t => (
|
||||
<button key={t} onClick={() => setSubTab(t)}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px ${subTab === t ? 'border-purple-600 text-purple-600' : 'border-transparent text-gray-500 hover:text-gray-700'}`}
|
||||
>
|
||||
{t === 'audits' ? 'Interne Audits' : t === 'findings' ? 'Findings' : 'CAPA'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{subTab === 'audits' && (
|
||||
<div className="space-y-3">
|
||||
{audits.length === 0 ? <EmptyState text="Noch keine internen Audits geplant" /> : audits.map(a => (
|
||||
<div key={a.id} className="bg-white border rounded-xl p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-xs text-gray-500">{a.audit_id}</span>
|
||||
<span className="text-sm font-medium text-gray-900">{a.title}</span>
|
||||
<StatusBadge status={a.status} />
|
||||
</div>
|
||||
<div className="flex gap-3 text-xs text-gray-500 mt-1">
|
||||
<span>Typ: {a.audit_type}</span>
|
||||
<span>Datum: {new Date(a.planned_date).toLocaleDateString('de-DE')}</span>
|
||||
<span>Auditor: {a.lead_auditor}</span>
|
||||
<span>Findings: {a.total_findings || 0} (Major: {a.major_findings || 0}, Minor: {a.minor_findings || 0})</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{a.audit_conclusion && (
|
||||
<p className="text-xs text-gray-600 mt-2 bg-gray-50 rounded p-2">{a.audit_conclusion}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{subTab === 'findings' && (
|
||||
<div className="space-y-3">
|
||||
{findings.length === 0 ? <EmptyState text="Keine Audit-Findings vorhanden" /> : findings.map(f => (
|
||||
<div key={f.id} className={`bg-white border rounded-xl p-4 ${f.is_blocking ? 'border-red-300' : ''}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-xs text-gray-500">{f.finding_id}</span>
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
f.finding_type === 'major' ? 'bg-red-100 text-red-700' :
|
||||
f.finding_type === 'minor' ? 'bg-yellow-100 text-yellow-700' :
|
||||
f.finding_type === 'ofi' ? 'bg-blue-100 text-blue-700' :
|
||||
'bg-green-100 text-green-700'
|
||||
}`}>{f.finding_type.toUpperCase()}</span>
|
||||
<span className="text-sm font-medium text-gray-900">{f.title}</span>
|
||||
<StatusBadge status={f.status} />
|
||||
{f.is_blocking && <span className="px-2 py-0.5 bg-red-600 text-white text-xs rounded-full">Blockiert</span>}
|
||||
</div>
|
||||
<p className="text-xs text-gray-600 mt-1">{f.description}</p>
|
||||
<div className="flex gap-3 text-xs text-gray-500 mt-1">
|
||||
<span>ISO: {f.iso_chapter}</span>
|
||||
<span>Verantwortlich: {f.owner}</span>
|
||||
<span>Auditor: {f.auditor}</span>
|
||||
{f.due_date && <span>Frist: {new Date(f.due_date).toLocaleDateString('de-DE')}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{subTab === 'capa' && (
|
||||
<div className="space-y-3">
|
||||
{capas.length === 0 ? <EmptyState text="Keine Korrektur-/Vorbeugungsmassnahmen vorhanden" /> : capas.map(c => (
|
||||
<div key={c.id} className="bg-white border rounded-xl p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-xs text-gray-500">{c.capa_id}</span>
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${c.capa_type === 'corrective' ? 'bg-red-100 text-red-700' : 'bg-blue-100 text-blue-700'}`}>
|
||||
{c.capa_type === 'corrective' ? 'Korrektur' : 'Vorbeugung'}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-gray-900">{c.title}</span>
|
||||
<StatusBadge status={c.status} />
|
||||
</div>
|
||||
<div className="flex gap-3 text-xs text-gray-500 mt-1">
|
||||
<span>Zustaendig: {c.assigned_to}</span>
|
||||
<span>Ziel: {new Date(c.planned_completion).toLocaleDateString('de-DE')}</span>
|
||||
{c.actual_completion && <span>Abgeschlossen: {new Date(c.actual_completion).toLocaleDateString('de-DE')}</span>}
|
||||
{c.effectiveness_verified !== null && (
|
||||
<span className={c.effectiveness_verified ? 'text-green-600' : 'text-red-600'}>
|
||||
Wirksamkeit: {c.effectiveness_verified ? 'Bestaetigt' : 'Nicht bestaetigt'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
163
admin-compliance/app/sdk/isms/_components/ObjectivesTab.tsx
Normal file
163
admin-compliance/app/sdk/isms/_components/ObjectivesTab.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { API, SecurityObjective } from '../_types'
|
||||
import { EmptyState, LoadingSpinner, StatusBadge } from './shared'
|
||||
|
||||
// =============================================================================
|
||||
// TAB: OBJECTIVES
|
||||
// =============================================================================
|
||||
|
||||
export function ObjectivesTab() {
|
||||
const [objectives, setObjectives] = useState<SecurityObjective[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch(`${API}/objectives`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setObjectives(data.objectives || [])
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
setLoading(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const createObjective = async (form: Record<string, unknown>) => {
|
||||
try {
|
||||
const res = await fetch(`${API}/objectives?created_by=admin`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(form),
|
||||
})
|
||||
if (res.ok) { setShowCreate(false); load() }
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
if (loading) return <LoadingSpinner />
|
||||
|
||||
const active = objectives.filter(o => o.status === 'active')
|
||||
const achieved = objectives.filter(o => o.status === 'achieved')
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex gap-3 text-sm text-gray-600">
|
||||
<span>Aktiv: {active.length}</span>
|
||||
<span>Erreicht: {achieved.length}</span>
|
||||
</div>
|
||||
<button onClick={() => setShowCreate(true)} className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 text-sm">Neues Ziel</button>
|
||||
</div>
|
||||
|
||||
{objectives.length === 0 ? (
|
||||
<EmptyState text="Keine Sicherheitsziele definiert" action="Ziel erstellen" onAction={() => setShowCreate(true)} />
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{objectives.map(o => (
|
||||
<div key={o.id} className="bg-white border rounded-xl p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-xs text-gray-500">{o.objective_id}</span>
|
||||
<span className="text-sm font-medium text-gray-900">{o.title}</span>
|
||||
<StatusBadge status={o.status} />
|
||||
</div>
|
||||
<span className="text-sm font-bold text-purple-600">{o.progress_percentage}%</span>
|
||||
</div>
|
||||
<div className="h-2 bg-gray-200 rounded-full overflow-hidden mb-2">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${o.progress_percentage >= 100 ? 'bg-green-500' : 'bg-purple-500'}`}
|
||||
style={{ width: `${Math.min(100, o.progress_percentage)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-4 text-xs text-gray-500">
|
||||
<span>KPI: {o.kpi_name} — Ziel: {o.kpi_target} {o.kpi_unit}</span>
|
||||
<span>Verantwortlich: {o.owner}</span>
|
||||
<span>Zieldatum: {new Date(o.target_date).toLocaleDateString('de-DE')}</span>
|
||||
<span>Messung: {o.measurement_frequency}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCreate && (
|
||||
<ObjectiveCreateModal onClose={() => setShowCreate(false)} onSave={createObjective} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ObjectiveCreateModal({ onClose, onSave }: { onClose: () => void; onSave: (data: Record<string, unknown>) => void }) {
|
||||
const [form, setForm] = useState({
|
||||
objective_id: '', title: '', description: '', category: 'confidentiality',
|
||||
specific: '', measurable: '', achievable: '', relevant: '', time_bound: '',
|
||||
kpi_name: '', kpi_target: 95, kpi_unit: '%', measurement_frequency: 'monthly',
|
||||
owner: '', target_date: '', related_controls: [] as string[], related_risks: [] as string[],
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-2xl max-h-[90vh] overflow-y-auto p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Neues Sicherheitsziel (SMART)</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-600">Ziel-ID</label>
|
||||
<input value={form.objective_id} onChange={e => setForm({ ...form, objective_id: e.target.value })} className="w-full border rounded-lg px-3 py-2 text-sm" placeholder="OBJ-001" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-600">Kategorie</label>
|
||||
<select value={form.category} onChange={e => setForm({ ...form, category: e.target.value })} className="w-full border rounded-lg px-3 py-2 text-sm">
|
||||
<option value="confidentiality">Vertraulichkeit</option>
|
||||
<option value="integrity">Integritaet</option>
|
||||
<option value="availability">Verfuegbarkeit</option>
|
||||
<option value="compliance">Compliance</option>
|
||||
<option value="awareness">Awareness</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-600">Titel</label>
|
||||
<input value={form.title} onChange={e => setForm({ ...form, title: e.target.value })} className="w-full border rounded-lg px-3 py-2 text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-600">Beschreibung</label>
|
||||
<textarea value={form.description} onChange={e => setForm({ ...form, description: e.target.value })} className="w-full border rounded-lg px-3 py-2 text-sm" rows={2} />
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-600">KPI Name</label>
|
||||
<input value={form.kpi_name} onChange={e => setForm({ ...form, kpi_name: e.target.value })} className="w-full border rounded-lg px-3 py-2 text-sm" placeholder="Patch-Rate" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-600">Zielwert</label>
|
||||
<input type="number" value={form.kpi_target} onChange={e => setForm({ ...form, kpi_target: Number(e.target.value) })} className="w-full border rounded-lg px-3 py-2 text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-600">Einheit</label>
|
||||
<input value={form.kpi_unit} onChange={e => setForm({ ...form, kpi_unit: e.target.value })} className="w-full border rounded-lg px-3 py-2 text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-600">Verantwortlich</label>
|
||||
<input value={form.owner} onChange={e => setForm({ ...form, owner: e.target.value })} className="w-full border rounded-lg px-3 py-2 text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-600">Zieldatum</label>
|
||||
<input type="date" value={form.target_date} onChange={e => setForm({ ...form, target_date: e.target.value })} className="w-full border rounded-lg px-3 py-2 text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 mt-6">
|
||||
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg">Abbrechen</button>
|
||||
<button onClick={() => onSave(form)} className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700">Erstellen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
196
admin-compliance/app/sdk/isms/_components/OverviewTab.tsx
Normal file
196
admin-compliance/app/sdk/isms/_components/OverviewTab.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { API, ISMSOverview, ISMSScope, ReadinessCheck } from '../_types'
|
||||
import { LoadingSpinner, StatCard, StatusBadge } from './shared'
|
||||
|
||||
// =============================================================================
|
||||
// TAB: OVERVIEW
|
||||
// =============================================================================
|
||||
|
||||
export function OverviewTab() {
|
||||
const [overview, setOverview] = useState<ISMSOverview | null>(null)
|
||||
const [readiness, setReadiness] = useState<ReadinessCheck | null>(null)
|
||||
const [scope, setScope] = useState<ISMSScope | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [running, setRunning] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [ovRes, rdRes, scRes] = await Promise.allSettled([
|
||||
fetch(`${API}/overview`),
|
||||
fetch(`${API}/readiness-check/latest`),
|
||||
fetch(`${API}/scope`),
|
||||
])
|
||||
if (ovRes.status === 'fulfilled' && ovRes.value.ok) setOverview(await ovRes.value.json())
|
||||
if (rdRes.status === 'fulfilled' && rdRes.value.ok) setReadiness(await rdRes.value.json())
|
||||
if (scRes.status === 'fulfilled' && scRes.value.ok) setScope(await scRes.value.json())
|
||||
} catch { /* ignore */ }
|
||||
setLoading(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const runCheck = async () => {
|
||||
setRunning(true)
|
||||
try {
|
||||
const res = await fetch(`${API}/readiness-check`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ triggered_by: 'admin-ui' }),
|
||||
})
|
||||
if (res.ok) {
|
||||
setReadiness(await res.json())
|
||||
load()
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
setRunning(false)
|
||||
}
|
||||
|
||||
if (loading) return <LoadingSpinner />
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Readiness Score */}
|
||||
<div className="bg-white border rounded-xl p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">ISO 27001 Zertifizierungsbereitschaft</h3>
|
||||
<button
|
||||
onClick={runCheck}
|
||||
disabled={running}
|
||||
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 text-sm"
|
||||
>
|
||||
{running ? 'Pruefe...' : 'Readiness-Check starten'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{overview && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6">
|
||||
<StatCard
|
||||
label="Bereitschaft"
|
||||
value={`${Math.round(overview.certification_readiness)}%`}
|
||||
color={overview.certification_readiness >= 80 ? 'green' : overview.certification_readiness >= 50 ? 'yellow' : 'red'}
|
||||
/>
|
||||
<StatCard label="Policies" value={`${overview.policies_approved}/${overview.policies_count}`} sub="genehmigt" color="blue" />
|
||||
<StatCard label="Ziele" value={`${overview.objectives_achieved}/${overview.objectives_count}`} sub="erreicht" color="blue" />
|
||||
<StatCard label="Major Findings" value={overview.open_major_findings} color={overview.open_major_findings > 0 ? 'red' : 'green'} />
|
||||
<StatCard label="Minor Findings" value={overview.open_minor_findings} color={overview.open_minor_findings > 0 ? 'yellow' : 'green'} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chapter Overview */}
|
||||
{overview?.chapters && (
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-700 mb-3">ISO 27001 Kapitel-Status</h4>
|
||||
<div className="space-y-2">
|
||||
{overview.chapters.map(ch => (
|
||||
<div key={ch.chapter} className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg">
|
||||
<span className="font-mono text-sm font-bold text-gray-600 w-8">Kap.{ch.chapter}</span>
|
||||
<span className="flex-1 text-sm font-medium text-gray-800">{ch.title}</span>
|
||||
<div className="w-32">
|
||||
<div className="h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full ${ch.completion_percentage >= 80 ? 'bg-green-500' : ch.completion_percentage >= 50 ? 'bg-yellow-500' : 'bg-red-500'}`}
|
||||
style={{ width: `${ch.completion_percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 w-10 text-right">{Math.round(ch.completion_percentage)}%</span>
|
||||
<StatusBadge status={ch.status} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Readiness Findings */}
|
||||
{readiness && (
|
||||
<div className="bg-white border rounded-xl p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Readiness-Check Ergebnis</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusBadge status={readiness.overall_status} size="md" />
|
||||
<span className="text-sm text-gray-500">Score: {Math.round(readiness.readiness_score)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{readiness.potential_majors.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<h4 className="text-sm font-medium text-red-700 mb-2">Potenzielle Major-Findings ({readiness.potential_majors.length})</h4>
|
||||
<div className="space-y-2">
|
||||
{readiness.potential_majors.map((f, i) => (
|
||||
<div key={i} className="flex items-start gap-3 p-3 bg-red-50 border border-red-200 rounded-lg">
|
||||
<span className="text-red-500 mt-0.5">✖</span>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">{f.check}</div>
|
||||
<div className="text-xs text-gray-600 mt-0.5">{f.recommendation}</div>
|
||||
<div className="text-xs text-gray-400 mt-0.5">ISO Referenz: {f.iso_reference}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{readiness.potential_minors.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<h4 className="text-sm font-medium text-yellow-700 mb-2">Potenzielle Minor-Findings ({readiness.potential_minors.length})</h4>
|
||||
<div className="space-y-2">
|
||||
{readiness.potential_minors.map((f, i) => (
|
||||
<div key={i} className="flex items-start gap-3 p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<span className="text-yellow-500 mt-0.5">⚠</span>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">{f.check}</div>
|
||||
<div className="text-xs text-gray-600 mt-0.5">{f.recommendation}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{readiness.priority_actions.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-2">Prioritaere Massnahmen</h4>
|
||||
<ol className="list-decimal list-inside text-sm text-gray-600 space-y-1">
|
||||
{readiness.priority_actions.map((a, i) => <li key={i}>{a}</li>)}
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scope Summary */}
|
||||
{scope && (
|
||||
<div className="bg-white border rounded-xl p-6">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-lg font-semibold text-gray-900">ISMS Scope (Kap. 4.3)</h3>
|
||||
<StatusBadge status={scope.status} size="md" />
|
||||
</div>
|
||||
<p className="text-sm text-gray-700 mb-3">{scope.scope_statement}</p>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="font-medium text-gray-600">Standorte:</span>
|
||||
<ul className="list-disc list-inside text-gray-700 mt-1">
|
||||
{scope.included_locations?.map((l, i) => <li key={i}>{l}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-gray-600">Prozesse:</span>
|
||||
<ul className="list-disc list-inside text-gray-700 mt-1">
|
||||
{scope.included_processes?.map((p, i) => <li key={i}>{p}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{scope.approved_by && (
|
||||
<div className="mt-3 text-xs text-gray-500">
|
||||
Genehmigt von {scope.approved_by} am {new Date(scope.approved_at!).toLocaleDateString('de-DE')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
169
admin-compliance/app/sdk/isms/_components/PoliciesTab.tsx
Normal file
169
admin-compliance/app/sdk/isms/_components/PoliciesTab.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { API, ISMSPolicy } from '../_types'
|
||||
import { EmptyState, LoadingSpinner, StatusBadge } from './shared'
|
||||
|
||||
// =============================================================================
|
||||
// TAB: POLICIES
|
||||
// =============================================================================
|
||||
|
||||
export function PoliciesTab() {
|
||||
const [policies, setPolicies] = useState<ISMSPolicy[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [filter, setFilter] = useState<string>('')
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const url = filter ? `${API}/policies?policy_type=${filter}` : `${API}/policies`
|
||||
const res = await fetch(url)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setPolicies(data.policies || [])
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
setLoading(false)
|
||||
}, [filter])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const createPolicy = async (form: Record<string, unknown>) => {
|
||||
try {
|
||||
const res = await fetch(`${API}/policies`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(form),
|
||||
})
|
||||
if (res.ok) { setShowCreate(false); load() }
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const approvePolicy = async (policyId: string) => {
|
||||
try {
|
||||
await fetch(`${API}/policies/${policyId}/approve`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
reviewed_by: 'admin',
|
||||
approved_by: 'admin',
|
||||
effective_date: new Date().toISOString().split('T')[0],
|
||||
}),
|
||||
})
|
||||
load()
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
if (loading) return <LoadingSpinner />
|
||||
|
||||
const policyTypes = ['master', 'topic', 'operational', 'standard']
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => setFilter('')} className={`px-3 py-1.5 rounded-lg text-sm ${!filter ? 'bg-purple-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}`}>Alle</button>
|
||||
{policyTypes.map(t => (
|
||||
<button key={t} onClick={() => setFilter(t)} className={`px-3 py-1.5 rounded-lg text-sm capitalize ${filter === t ? 'bg-purple-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}`}>{t}</button>
|
||||
))}
|
||||
</div>
|
||||
<button onClick={() => setShowCreate(true)} className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 text-sm">Neue Policy</button>
|
||||
</div>
|
||||
|
||||
{policies.length === 0 ? (
|
||||
<EmptyState text="Keine Policies vorhanden" action="Policy erstellen" onAction={() => setShowCreate(true)} />
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{policies.map(p => (
|
||||
<div key={p.id} className="bg-white border rounded-xl p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-xs text-gray-500">{p.policy_id}</span>
|
||||
<span className="text-sm font-medium text-gray-900">{p.title}</span>
|
||||
<StatusBadge status={p.status} />
|
||||
<span className="text-xs text-gray-400">v{p.version}</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600 mt-1 line-clamp-2">{p.description}</p>
|
||||
<div className="flex gap-3 mt-1 text-xs text-gray-500">
|
||||
<span>Typ: {p.policy_type}</span>
|
||||
<span>Review: alle {p.review_frequency_months} Monate</span>
|
||||
{p.next_review_date && <span>Naechste Review: {new Date(p.next_review_date).toLocaleDateString('de-DE')}</span>}
|
||||
</div>
|
||||
</div>
|
||||
{p.status === 'draft' && (
|
||||
<button onClick={() => approvePolicy(p.id)} className="px-3 py-1.5 bg-green-600 text-white rounded-lg hover:bg-green-700 text-xs">Genehmigen</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Modal */}
|
||||
{showCreate && (
|
||||
<PolicyCreateModal onClose={() => setShowCreate(false)} onSave={createPolicy} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PolicyCreateModal({ onClose, onSave }: { onClose: () => void; onSave: (data: Record<string, unknown>) => void }) {
|
||||
const [form, setForm] = useState({
|
||||
policy_id: '', title: '', policy_type: 'topic', description: '', policy_text: '',
|
||||
applies_to: ['Alle Mitarbeiter'], review_frequency_months: 12, related_controls: [] as string[],
|
||||
authored_by: 'admin',
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-2xl max-h-[90vh] overflow-y-auto p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Neue ISMS Policy</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-600">Policy-ID</label>
|
||||
<input value={form.policy_id} onChange={e => setForm({ ...form, policy_id: e.target.value })} className="w-full border rounded-lg px-3 py-2 text-sm" placeholder="z.B. POL-SEC-001" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-600">Typ</label>
|
||||
<select value={form.policy_type} onChange={e => setForm({ ...form, policy_type: e.target.value })} className="w-full border rounded-lg px-3 py-2 text-sm">
|
||||
<option value="master">Master Policy</option>
|
||||
<option value="topic">Topic Policy</option>
|
||||
<option value="operational">Operational</option>
|
||||
<option value="standard">Standard</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-600">Titel</label>
|
||||
<input value={form.title} onChange={e => setForm({ ...form, title: e.target.value })} className="w-full border rounded-lg px-3 py-2 text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-600">Beschreibung</label>
|
||||
<textarea value={form.description} onChange={e => setForm({ ...form, description: e.target.value })} className="w-full border rounded-lg px-3 py-2 text-sm" rows={2} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-600">Policy-Text</label>
|
||||
<textarea value={form.policy_text} onChange={e => setForm({ ...form, policy_text: e.target.value })} className="w-full border rounded-lg px-3 py-2 text-sm" rows={5} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-600">Review-Frequenz (Monate)</label>
|
||||
<input type="number" value={form.review_frequency_months} onChange={e => setForm({ ...form, review_frequency_months: Number(e.target.value) })} className="w-full border rounded-lg px-3 py-2 text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-600">Autor</label>
|
||||
<input value={form.authored_by} onChange={e => setForm({ ...form, authored_by: e.target.value })} className="w-full border rounded-lg px-3 py-2 text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 mt-6">
|
||||
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg">Abbrechen</button>
|
||||
<button onClick={() => onSave(form)} className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700">Erstellen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
145
admin-compliance/app/sdk/isms/_components/ReviewsTab.tsx
Normal file
145
admin-compliance/app/sdk/isms/_components/ReviewsTab.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { API, ManagementReview } from '../_types'
|
||||
import { EmptyState, LoadingSpinner, StatusBadge } from './shared'
|
||||
|
||||
// =============================================================================
|
||||
// TAB: MANAGEMENT REVIEWS
|
||||
// =============================================================================
|
||||
|
||||
export function ReviewsTab() {
|
||||
const [reviews, setReviews] = useState<ManagementReview[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch(`${API}/management-reviews`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setReviews(data.reviews || [])
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
setLoading(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const createReview = async (form: Record<string, unknown>) => {
|
||||
try {
|
||||
const res = await fetch(`${API}/management-reviews?created_by=admin`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(form),
|
||||
})
|
||||
if (res.ok) { setShowCreate(false); load() }
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const approveReview = async (reviewId: string) => {
|
||||
const nextYear = new Date()
|
||||
nextYear.setFullYear(nextYear.getFullYear() + 1)
|
||||
try {
|
||||
await fetch(`${API}/management-reviews/${reviewId}/approve`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
approved_by: 'admin',
|
||||
next_review_date: nextYear.toISOString().split('T')[0],
|
||||
}),
|
||||
})
|
||||
load()
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
if (loading) return <LoadingSpinner />
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-gray-600">{reviews.length} Management-Reviews</h3>
|
||||
<button onClick={() => setShowCreate(true)} className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 text-sm">Neue Review</button>
|
||||
</div>
|
||||
|
||||
{reviews.length === 0 ? (
|
||||
<EmptyState text="Keine Management-Reviews vorhanden" action="Review planen" onAction={() => setShowCreate(true)} />
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{reviews.map(r => (
|
||||
<div key={r.id} className="bg-white border rounded-xl p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-xs text-gray-500">{r.review_id}</span>
|
||||
<span className="text-sm font-medium text-gray-900">{r.title}</span>
|
||||
<StatusBadge status={r.status} />
|
||||
</div>
|
||||
<div className="flex gap-3 text-xs text-gray-500 mt-1">
|
||||
<span>Datum: {new Date(r.review_date).toLocaleDateString('de-DE')}</span>
|
||||
<span>Zeitraum: {new Date(r.review_period_start).toLocaleDateString('de-DE')} - {new Date(r.review_period_end).toLocaleDateString('de-DE')}</span>
|
||||
<span>Vorsitz: {r.chairperson}</span>
|
||||
{r.next_review_date && <span>Naechste Review: {new Date(r.next_review_date).toLocaleDateString('de-DE')}</span>}
|
||||
</div>
|
||||
</div>
|
||||
{r.status === 'draft' && (
|
||||
<button onClick={() => approveReview(r.id)} className="px-3 py-1.5 bg-green-600 text-white rounded-lg hover:bg-green-700 text-xs">Genehmigen</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCreate && (
|
||||
<ReviewCreateModal onClose={() => setShowCreate(false)} onSave={createReview} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ReviewCreateModal({ onClose, onSave }: { onClose: () => void; onSave: (data: Record<string, unknown>) => void }) {
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
const [form, setForm] = useState({
|
||||
title: '', review_date: today,
|
||||
review_period_start: '', review_period_end: today,
|
||||
chairperson: '', attendees: [] as Record<string, unknown>[],
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-lg p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Neue Management-Review</h3>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-600">Titel</label>
|
||||
<input value={form.title} onChange={e => setForm({ ...form, title: e.target.value })} className="w-full border rounded-lg px-3 py-2 text-sm" placeholder="Q1 2026 Management Review" />
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-600">Review-Datum</label>
|
||||
<input type="date" value={form.review_date} onChange={e => setForm({ ...form, review_date: e.target.value })} className="w-full border rounded-lg px-3 py-2 text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-600">Zeitraum von</label>
|
||||
<input type="date" value={form.review_period_start} onChange={e => setForm({ ...form, review_period_start: e.target.value })} className="w-full border rounded-lg px-3 py-2 text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-600">Zeitraum bis</label>
|
||||
<input type="date" value={form.review_period_end} onChange={e => setForm({ ...form, review_period_end: e.target.value })} className="w-full border rounded-lg px-3 py-2 text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-600">Vorsitzender</label>
|
||||
<input value={form.chairperson} onChange={e => setForm({ ...form, chairperson: e.target.value })} className="w-full border rounded-lg px-3 py-2 text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 mt-6">
|
||||
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg">Abbrechen</button>
|
||||
<button onClick={() => onSave(form)} className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700">Erstellen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
107
admin-compliance/app/sdk/isms/_components/SoATab.tsx
Normal file
107
admin-compliance/app/sdk/isms/_components/SoATab.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { API, SoAEntry } from '../_types'
|
||||
import { EmptyState, LoadingSpinner, StatCard, StatusBadge } from './shared'
|
||||
|
||||
// =============================================================================
|
||||
// TAB: SOA (Statement of Applicability)
|
||||
// =============================================================================
|
||||
|
||||
export function SoATab() {
|
||||
const [entries, setEntries] = useState<SoAEntry[]>([])
|
||||
const [stats, setStats] = useState({ applicable: 0, notApplicable: 0, implemented: 0, planned: 0 })
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [filterStatus, setFilterStatus] = useState('')
|
||||
const [filterApplicable, setFilterApplicable] = useState<string>('')
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
if (filterStatus) params.set('implementation_status', filterStatus)
|
||||
if (filterApplicable) params.set('is_applicable', filterApplicable)
|
||||
const res = await fetch(`${API}/soa?${params}`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setEntries(data.entries || [])
|
||||
setStats({
|
||||
applicable: data.applicable_count || 0,
|
||||
notApplicable: data.not_applicable_count || 0,
|
||||
implemented: data.implemented_count || 0,
|
||||
planned: data.planned_count || 0,
|
||||
})
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
setLoading(false)
|
||||
}, [filterStatus, filterApplicable])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
if (loading) return <LoadingSpinner />
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<StatCard label="Anwendbar" value={stats.applicable} color="blue" />
|
||||
<StatCard label="Nicht anwendbar" value={stats.notApplicable} color="purple" />
|
||||
<StatCard label="Implementiert" value={stats.implemented} color="green" />
|
||||
<StatCard label="Geplant" value={stats.planned} color="yellow" />
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex gap-2">
|
||||
<select value={filterApplicable} onChange={e => setFilterApplicable(e.target.value)} className="border rounded-lg px-3 py-1.5 text-sm">
|
||||
<option value="">Alle Controls</option>
|
||||
<option value="true">Anwendbar</option>
|
||||
<option value="false">Nicht anwendbar</option>
|
||||
</select>
|
||||
<select value={filterStatus} onChange={e => setFilterStatus(e.target.value)} className="border rounded-lg px-3 py-1.5 text-sm">
|
||||
<option value="">Alle Status</option>
|
||||
<option value="implemented">Implementiert</option>
|
||||
<option value="planned">Geplant</option>
|
||||
<option value="not_started">Nicht gestartet</option>
|
||||
<option value="not_applicable">N/A</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{entries.length === 0 ? (
|
||||
<EmptyState text="Noch keine SoA-Eintraege vorhanden. Erstellen Sie Eintraege fuer alle 93 Annex-A-Controls." />
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 text-left">
|
||||
<th className="px-3 py-2 font-medium text-gray-600">Control</th>
|
||||
<th className="px-3 py-2 font-medium text-gray-600">Titel</th>
|
||||
<th className="px-3 py-2 font-medium text-gray-600">Kategorie</th>
|
||||
<th className="px-3 py-2 font-medium text-gray-600">Anwendbar</th>
|
||||
<th className="px-3 py-2 font-medium text-gray-600">Status</th>
|
||||
<th className="px-3 py-2 font-medium text-gray-600">Coverage</th>
|
||||
<th className="px-3 py-2 font-medium text-gray-600">Version</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map(e => (
|
||||
<tr key={e.id} className="border-t hover:bg-gray-50">
|
||||
<td className="px-3 py-2 font-mono text-xs">{e.annex_a_control}</td>
|
||||
<td className="px-3 py-2 text-gray-800">{e.annex_a_title}</td>
|
||||
<td className="px-3 py-2 text-gray-600">{e.annex_a_category}</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs ${e.is_applicable ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600'}`}>
|
||||
{e.is_applicable ? 'Ja' : 'Nein'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2"><StatusBadge status={e.implementation_status} /></td>
|
||||
<td className="px-3 py-2 text-xs text-gray-600">{e.coverage_level || '-'}</td>
|
||||
<td className="px-3 py-2 text-xs text-gray-400">v{e.version}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
86
admin-compliance/app/sdk/isms/_components/shared.tsx
Normal file
86
admin-compliance/app/sdk/isms/_components/shared.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
|
||||
// =============================================================================
|
||||
// HELPER COMPONENTS
|
||||
// =============================================================================
|
||||
|
||||
export function StatusBadge({ status, size = 'sm' }: { status: string; size?: 'sm' | 'md' }) {
|
||||
const colors: Record<string, string> = {
|
||||
ready: 'bg-green-100 text-green-700',
|
||||
compliant: 'bg-green-100 text-green-700',
|
||||
approved: 'bg-green-100 text-green-700',
|
||||
pass: 'bg-green-100 text-green-700',
|
||||
implemented: 'bg-green-100 text-green-700',
|
||||
completed: 'bg-green-100 text-green-700',
|
||||
verified: 'bg-green-100 text-green-700',
|
||||
achieved: 'bg-green-100 text-green-700',
|
||||
closed: 'bg-green-100 text-green-700',
|
||||
at_risk: 'bg-yellow-100 text-yellow-700',
|
||||
partial: 'bg-yellow-100 text-yellow-700',
|
||||
warning: 'bg-yellow-100 text-yellow-700',
|
||||
planned: 'bg-blue-100 text-blue-700',
|
||||
draft: 'bg-gray-100 text-gray-700',
|
||||
active: 'bg-blue-100 text-blue-700',
|
||||
in_progress: 'bg-blue-100 text-blue-700',
|
||||
not_ready: 'bg-red-100 text-red-700',
|
||||
non_compliant: 'bg-red-100 text-red-700',
|
||||
fail: 'bg-red-100 text-red-700',
|
||||
open: 'bg-red-100 text-red-700',
|
||||
corrective_action_pending: 'bg-orange-100 text-orange-700',
|
||||
verification_pending: 'bg-yellow-100 text-yellow-700',
|
||||
}
|
||||
const cls = colors[status] || 'bg-gray-100 text-gray-600'
|
||||
const labels: Record<string, string> = {
|
||||
ready: 'Bereit', not_ready: 'Nicht bereit', at_risk: 'Risiko',
|
||||
compliant: 'Konform', non_compliant: 'Nicht konform', partial: 'Teilweise',
|
||||
approved: 'Genehmigt', draft: 'Entwurf', pass: 'Bestanden', fail: 'Fehlgeschlagen',
|
||||
warning: 'Warnung', implemented: 'Implementiert', planned: 'Geplant',
|
||||
active: 'Aktiv', achieved: 'Erreicht', completed: 'Abgeschlossen',
|
||||
open: 'Offen', closed: 'Geschlossen', verified: 'Verifiziert',
|
||||
corrective_action_pending: 'CAPA ausstehend', verification_pending: 'Verifizierung',
|
||||
in_progress: 'In Bearbeitung',
|
||||
not_applicable: 'N/A',
|
||||
}
|
||||
const pad = size === 'md' ? 'px-3 py-1 text-sm' : 'px-2 py-0.5 text-xs'
|
||||
return <span className={`${cls} ${pad} rounded-full font-medium`}>{labels[status] || status}</span>
|
||||
}
|
||||
|
||||
export function StatCard({ label, value, sub, color = 'purple' }: { label: string; value: string | number; sub?: string; color?: string }) {
|
||||
const colors: Record<string, string> = {
|
||||
purple: 'border-purple-200 bg-purple-50',
|
||||
green: 'border-green-200 bg-green-50',
|
||||
red: 'border-red-200 bg-red-50',
|
||||
yellow: 'border-yellow-200 bg-yellow-50',
|
||||
blue: 'border-blue-200 bg-blue-50',
|
||||
}
|
||||
return (
|
||||
<div className={`border rounded-xl p-4 ${colors[color] || colors.purple}`}>
|
||||
<div className="text-2xl font-bold text-gray-900">{value}</div>
|
||||
<div className="text-sm font-medium text-gray-700 mt-1">{label}</div>
|
||||
{sub && <div className="text-xs text-gray-500 mt-0.5">{sub}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function LoadingSpinner() {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function EmptyState({ text, action, onAction }: { text: string; action?: string; onAction?: () => void }) {
|
||||
return (
|
||||
<div className="text-center py-16 text-gray-500">
|
||||
<p>{text}</p>
|
||||
{action && onAction && (
|
||||
<button onClick={onAction} className="mt-3 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 text-sm">
|
||||
{action}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
216
admin-compliance/app/sdk/isms/_types.ts
Normal file
216
admin-compliance/app/sdk/isms/_types.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
export interface ISMSOverview {
|
||||
overall_status: string
|
||||
certification_readiness: number
|
||||
chapters: ChapterStatus[]
|
||||
scope_approved: boolean
|
||||
soa_approved: boolean
|
||||
last_management_review: string | null
|
||||
last_internal_audit: string | null
|
||||
open_major_findings: number
|
||||
open_minor_findings: number
|
||||
policies_count: number
|
||||
policies_approved: number
|
||||
objectives_count: number
|
||||
objectives_achieved: number
|
||||
}
|
||||
|
||||
export interface ChapterStatus {
|
||||
chapter: string
|
||||
title: string
|
||||
status: string
|
||||
completion_percentage: number
|
||||
open_findings: number
|
||||
key_documents: string[]
|
||||
last_reviewed: string | null
|
||||
}
|
||||
|
||||
export interface ISMSScope {
|
||||
id: string
|
||||
scope_statement: string
|
||||
included_locations: string[]
|
||||
included_processes: string[]
|
||||
included_services: string[]
|
||||
excluded_items: string[]
|
||||
exclusion_justification: string
|
||||
organizational_boundary: string
|
||||
physical_boundary: string
|
||||
technical_boundary: string
|
||||
status: string
|
||||
version: string
|
||||
created_by: string
|
||||
approved_by: string | null
|
||||
approved_at: string | null
|
||||
effective_date: string | null
|
||||
review_date: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface ISMSContext {
|
||||
id: string
|
||||
internal_issues: Record<string, unknown>[] | null
|
||||
external_issues: Record<string, unknown>[] | null
|
||||
interested_parties: Record<string, unknown>[] | null
|
||||
regulatory_requirements: string[]
|
||||
contractual_requirements: string[]
|
||||
swot_strengths: string[]
|
||||
swot_weaknesses: string[]
|
||||
swot_opportunities: string[]
|
||||
swot_threats: string[]
|
||||
status: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface ISMSPolicy {
|
||||
id: string
|
||||
policy_id: string
|
||||
title: string
|
||||
policy_type: string
|
||||
description: string
|
||||
policy_text: string
|
||||
applies_to: string[]
|
||||
review_frequency_months: number
|
||||
related_controls: string[]
|
||||
status: string
|
||||
version: string
|
||||
authored_by: string
|
||||
reviewed_by: string | null
|
||||
approved_by: string | null
|
||||
approved_at: string | null
|
||||
effective_date: string | null
|
||||
next_review_date: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface SecurityObjective {
|
||||
id: string
|
||||
objective_id: string
|
||||
title: string
|
||||
description: string
|
||||
category: string
|
||||
kpi_name: string
|
||||
kpi_target: number
|
||||
kpi_unit: string
|
||||
measurement_frequency: string
|
||||
owner: string
|
||||
target_date: string
|
||||
progress_percentage: number
|
||||
status: string
|
||||
achieved_date: string | null
|
||||
}
|
||||
|
||||
export interface SoAEntry {
|
||||
id: string
|
||||
annex_a_control: string
|
||||
annex_a_title: string
|
||||
annex_a_category: string
|
||||
is_applicable: boolean
|
||||
applicability_justification: string
|
||||
implementation_status: string
|
||||
implementation_notes: string
|
||||
breakpilot_control_ids: string[]
|
||||
coverage_level: string
|
||||
evidence_description: string
|
||||
version: string
|
||||
approved_at: string | null
|
||||
}
|
||||
|
||||
export interface InternalAudit {
|
||||
id: string
|
||||
audit_id: string
|
||||
title: string
|
||||
audit_type: string
|
||||
scope_description: string
|
||||
iso_chapters_covered: string[]
|
||||
planned_date: string
|
||||
actual_end_date: string | null
|
||||
lead_auditor: string
|
||||
audit_team: string[]
|
||||
status: string
|
||||
total_findings: number
|
||||
major_findings: number
|
||||
minor_findings: number
|
||||
ofi_count: number
|
||||
audit_conclusion: string | null
|
||||
overall_assessment: string | null
|
||||
}
|
||||
|
||||
export interface AuditFinding {
|
||||
id: string
|
||||
finding_id: string
|
||||
finding_type: string
|
||||
iso_chapter: string
|
||||
annex_a_control: string | null
|
||||
title: string
|
||||
description: string
|
||||
objective_evidence: string
|
||||
owner: string
|
||||
auditor: string
|
||||
status: string
|
||||
due_date: string | null
|
||||
closed_date: string | null
|
||||
internal_audit_id: string | null
|
||||
is_blocking: boolean
|
||||
}
|
||||
|
||||
export interface CAPA {
|
||||
id: string
|
||||
capa_id: string
|
||||
finding_id: string
|
||||
capa_type: string
|
||||
title: string
|
||||
description: string
|
||||
assigned_to: string
|
||||
status: string
|
||||
planned_completion: string
|
||||
actual_completion: string | null
|
||||
effectiveness_verified: boolean | null
|
||||
}
|
||||
|
||||
export interface ManagementReview {
|
||||
id: string
|
||||
review_id: string
|
||||
title: string
|
||||
review_date: string
|
||||
review_period_start: string
|
||||
review_period_end: string
|
||||
chairperson: string
|
||||
attendees: Record<string, unknown>[] | null
|
||||
status: string
|
||||
approved_by: string | null
|
||||
approved_at: string | null
|
||||
next_review_date: string | null
|
||||
action_items: Record<string, unknown>[] | null
|
||||
}
|
||||
|
||||
export interface ReadinessCheck {
|
||||
id: string
|
||||
check_date: string
|
||||
overall_status: string
|
||||
certification_possible: boolean
|
||||
readiness_score: number
|
||||
chapter_4_status: string
|
||||
chapter_5_status: string
|
||||
chapter_6_status: string
|
||||
chapter_7_status: string
|
||||
chapter_8_status: string
|
||||
chapter_9_status: string
|
||||
chapter_10_status: string
|
||||
potential_majors: PotentialFinding[]
|
||||
potential_minors: PotentialFinding[]
|
||||
priority_actions: string[]
|
||||
}
|
||||
|
||||
export interface PotentialFinding {
|
||||
check: string
|
||||
status: string
|
||||
recommendation: string
|
||||
iso_reference: string
|
||||
}
|
||||
|
||||
export type TabId = 'overview' | 'policies' | 'soa' | 'objectives' | 'audits' | 'reviews'
|
||||
|
||||
export const API = '/api/sdk/v1/isms'
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,460 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import {
|
||||
LoeschfristPolicy, LegalHold, StorageLocation,
|
||||
RETENTION_DRIVER_META, RetentionDriverType, DeletionMethodType,
|
||||
DELETION_METHOD_LABELS, STATUS_LABELS,
|
||||
STORAGE_LOCATION_LABELS, StorageLocationType, PolicyStatus,
|
||||
ReviewInterval, DeletionTriggerLevel, RetentionUnit,
|
||||
LegalHoldStatus, REVIEW_INTERVAL_LABELS,
|
||||
} from '@/lib/sdk/loeschfristen-types'
|
||||
import { TagInput } from './TagInput'
|
||||
import { renderTriggerBadge } from './UebersichtTab'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared type
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type SetFn = <K extends keyof LoeschfristPolicy>(key: K, val: LoeschfristPolicy[K]) => void
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sektion 1: Datenobjekt
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function DataObjectSection({ policy, set }: { policy: LoeschfristPolicy; set: SetFn }) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6 space-y-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">1. Datenobjekt</h3>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Name des Datenobjekts *</label>
|
||||
<input type="text" value={policy.dataObjectName} onChange={(e) => set('dataObjectName', e.target.value)}
|
||||
placeholder="z.B. Bewerbungsunterlagen"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung</label>
|
||||
<textarea value={policy.description} onChange={(e) => set('description', e.target.value)} rows={3}
|
||||
placeholder="Beschreibung des Datenobjekts und seiner Verarbeitung..."
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Betroffene Personengruppen</label>
|
||||
<TagInput value={policy.affectedGroups} onChange={(v) => set('affectedGroups', v)}
|
||||
placeholder="z.B. Bewerber, Mitarbeiter... (Enter zum Hinzufuegen)" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Datenkategorien</label>
|
||||
<TagInput value={policy.dataCategories} onChange={(v) => set('dataCategories', v)}
|
||||
placeholder="z.B. Stammdaten, Kontaktdaten... (Enter zum Hinzufuegen)" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Primaerer Verarbeitungszweck</label>
|
||||
<textarea value={policy.primaryPurpose} onChange={(e) => set('primaryPurpose', e.target.value)} rows={2}
|
||||
placeholder="Welchem Zweck dient die Verarbeitung dieser Daten?"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sektion 2: 3-stufige Loeschlogik
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function DeletionLogicSection({
|
||||
policy, pid, set, updateLegalHoldItem, addLegalHold, removeLegalHold,
|
||||
}: {
|
||||
policy: LoeschfristPolicy; pid: string; set: SetFn
|
||||
updateLegalHoldItem: (idx: number, updater: (h: LegalHold) => LegalHold) => void
|
||||
addLegalHold: (policyId: string) => void
|
||||
removeLegalHold: (policyId: string, idx: number) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6 space-y-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">2. 3-stufige Loeschlogik</h3>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Loeschausloeser (Trigger-Stufe)</label>
|
||||
<div className="space-y-2">
|
||||
{(['PURPOSE_END', 'RETENTION_DRIVER', 'LEGAL_HOLD'] as DeletionTriggerLevel[]).map((trigger) => (
|
||||
<label key={trigger} className="flex items-start gap-3 p-3 border border-gray-200 rounded-lg hover:bg-gray-50 cursor-pointer">
|
||||
<input type="radio" name={`trigger-${pid}`} checked={policy.deletionTrigger === trigger}
|
||||
onChange={() => set('deletionTrigger', trigger)} className="mt-0.5 text-purple-600 focus:ring-purple-500" />
|
||||
<div>
|
||||
<div className="flex items-center gap-2">{renderTriggerBadge(trigger)}</div>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{trigger === 'PURPOSE_END' && 'Loeschung nach Wegfall des Verarbeitungszwecks'}
|
||||
{trigger === 'RETENTION_DRIVER' && 'Loeschung nach Ablauf gesetzlicher oder vertraglicher Aufbewahrungsfrist'}
|
||||
{trigger === 'LEGAL_HOLD' && 'Loeschung durch aktiven Legal Hold blockiert'}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{policy.deletionTrigger === 'RETENTION_DRIVER' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Aufbewahrungstreiber</label>
|
||||
<select value={policy.retentionDriver}
|
||||
onChange={(e) => {
|
||||
const driver = e.target.value as RetentionDriverType
|
||||
const meta = RETENTION_DRIVER_META[driver]
|
||||
set('retentionDriver', driver)
|
||||
if (meta) {
|
||||
set('retentionDuration', meta.defaultDuration)
|
||||
set('retentionUnit', meta.defaultUnit as RetentionUnit)
|
||||
set('retentionDescription', meta.description)
|
||||
}
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500">
|
||||
<option value="">Bitte waehlen...</option>
|
||||
{Object.entries(RETENTION_DRIVER_META).map(([key, meta]) => (
|
||||
<option key={key} value={key}>{meta.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Aufbewahrungsdauer</label>
|
||||
<input type="number" min={0} value={policy.retentionDuration}
|
||||
onChange={(e) => set('retentionDuration', parseInt(e.target.value) || 0)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Einheit</label>
|
||||
<select value={policy.retentionUnit} onChange={(e) => set('retentionUnit', e.target.value as RetentionUnit)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500">
|
||||
<option value="DAYS">Tage</option>
|
||||
<option value="MONTHS">Monate</option>
|
||||
<option value="YEARS">Jahre</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung der Aufbewahrungspflicht</label>
|
||||
<input type="text" value={policy.retentionDescription} onChange={(e) => set('retentionDescription', e.target.value)}
|
||||
placeholder="z.B. Handelsrechtliche Aufbewahrungspflicht gem. HGB"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Startereignis (Fristbeginn)</label>
|
||||
<input type="text" value={policy.startEvent} onChange={(e) => set('startEvent', e.target.value)}
|
||||
placeholder="z.B. Ende des Geschaeftsjahres, Vertragsende..."
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500" />
|
||||
</div>
|
||||
|
||||
{/* Legal Holds */}
|
||||
<div className="border-t border-gray-200 pt-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="text-sm font-semibold text-gray-800">Legal Holds</h4>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={policy.hasActiveLegalHold}
|
||||
onChange={(e) => set('hasActiveLegalHold', e.target.checked)}
|
||||
className="text-purple-600 focus:ring-purple-500 rounded" />
|
||||
Aktiver Legal Hold
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{policy.legalHolds.length > 0 && (
|
||||
<div className="overflow-x-auto mb-3">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-lg">
|
||||
<thead>
|
||||
<tr className="bg-gray-50">
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Bezeichnung</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Grund</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Status</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Erstellt am</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{policy.legalHolds.map((hold, idx) => (
|
||||
<tr key={idx} className="border-t border-gray-100">
|
||||
<td className="px-3 py-2">
|
||||
<input type="text" value={hold.name}
|
||||
onChange={(e) => updateLegalHoldItem(idx, (h) => ({ ...h, name: e.target.value }))}
|
||||
placeholder="Bezeichnung"
|
||||
className="w-full px-2 py-1 border border-gray-200 rounded text-sm focus:ring-1 focus:ring-purple-500" />
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<input type="text" value={hold.reason}
|
||||
onChange={(e) => updateLegalHoldItem(idx, (h) => ({ ...h, reason: e.target.value }))}
|
||||
placeholder="Grund"
|
||||
className="w-full px-2 py-1 border border-gray-200 rounded text-sm focus:ring-1 focus:ring-purple-500" />
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<select value={hold.status}
|
||||
onChange={(e) => updateLegalHoldItem(idx, (h) => ({ ...h, status: e.target.value as LegalHoldStatus }))}
|
||||
className="px-2 py-1 border border-gray-200 rounded text-sm focus:ring-1 focus:ring-purple-500">
|
||||
<option value="ACTIVE">Aktiv</option>
|
||||
<option value="RELEASED">Aufgehoben</option>
|
||||
<option value="EXPIRED">Abgelaufen</option>
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<input type="date" value={hold.createdAt}
|
||||
onChange={(e) => updateLegalHoldItem(idx, (h) => ({ ...h, createdAt: e.target.value }))}
|
||||
className="px-2 py-1 border border-gray-200 rounded text-sm focus:ring-1 focus:ring-purple-500" />
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<button onClick={() => removeLegalHold(pid, idx)}
|
||||
className="text-red-500 hover:text-red-700 text-sm font-medium">Entfernen</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button onClick={() => addLegalHold(pid)} className="text-sm text-purple-600 hover:text-purple-800 font-medium">
|
||||
+ Legal Hold hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sektion 3: Speicherorte & Loeschmethode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function StorageSection({
|
||||
policy, pid, set, updateStorageLocationItem, addStorageLocation, removeStorageLocation,
|
||||
}: {
|
||||
policy: LoeschfristPolicy; pid: string; set: SetFn
|
||||
updateStorageLocationItem: (idx: number, updater: (s: StorageLocation) => StorageLocation) => void
|
||||
addStorageLocation: (policyId: string) => void
|
||||
removeStorageLocation: (policyId: string, idx: number) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6 space-y-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">3. Speicherorte & Loeschmethode</h3>
|
||||
|
||||
{policy.storageLocations.length > 0 && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-lg">
|
||||
<thead>
|
||||
<tr className="bg-gray-50">
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Name</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Typ</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Backup</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Anbieter</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Loeschfaehig</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{policy.storageLocations.map((loc, idx) => (
|
||||
<tr key={idx} className="border-t border-gray-100">
|
||||
<td className="px-3 py-2">
|
||||
<input type="text" value={loc.name}
|
||||
onChange={(e) => updateStorageLocationItem(idx, (s) => ({ ...s, name: e.target.value }))}
|
||||
placeholder="Name"
|
||||
className="w-full px-2 py-1 border border-gray-200 rounded text-sm focus:ring-1 focus:ring-purple-500" />
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<select value={loc.type}
|
||||
onChange={(e) => updateStorageLocationItem(idx, (s) => ({ ...s, type: e.target.value as StorageLocationType }))}
|
||||
className="px-2 py-1 border border-gray-200 rounded text-sm focus:ring-1 focus:ring-purple-500">
|
||||
{Object.entries(STORAGE_LOCATION_LABELS).map(([key, label]) => (
|
||||
<option key={key} value={key}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<input type="checkbox" checked={loc.isBackup}
|
||||
onChange={(e) => updateStorageLocationItem(idx, (s) => ({ ...s, isBackup: e.target.checked }))}
|
||||
className="text-purple-600 focus:ring-purple-500 rounded" />
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<input type="text" value={loc.provider}
|
||||
onChange={(e) => updateStorageLocationItem(idx, (s) => ({ ...s, provider: e.target.value }))}
|
||||
placeholder="Anbieter"
|
||||
className="w-full px-2 py-1 border border-gray-200 rounded text-sm focus:ring-1 focus:ring-purple-500" />
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<input type="checkbox" checked={loc.deletionCapable}
|
||||
onChange={(e) => updateStorageLocationItem(idx, (s) => ({ ...s, deletionCapable: e.target.checked }))}
|
||||
className="text-purple-600 focus:ring-purple-500 rounded" />
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<button onClick={() => removeStorageLocation(pid, idx)}
|
||||
className="text-red-500 hover:text-red-700 text-sm font-medium">Entfernen</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button onClick={() => addStorageLocation(pid)} className="text-sm text-purple-600 hover:text-purple-800 font-medium">
|
||||
+ Speicherort hinzufuegen
|
||||
</button>
|
||||
|
||||
<div className="border-t border-gray-200 pt-4 grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Loeschmethode</label>
|
||||
<select value={policy.deletionMethod} onChange={(e) => set('deletionMethod', e.target.value as DeletionMethodType)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500">
|
||||
{Object.entries(DELETION_METHOD_LABELS).map(([key, label]) => (
|
||||
<option key={key} value={key}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Details zur Loeschmethode</label>
|
||||
<textarea value={policy.deletionMethodDetail} onChange={(e) => set('deletionMethodDetail', e.target.value)} rows={2}
|
||||
placeholder="Weitere Details zum Loeschverfahren..."
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sektion 4: Verantwortlichkeit
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function ResponsibilitySection({ policy, set }: { policy: LoeschfristPolicy; set: SetFn }) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6 space-y-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">4. Verantwortlichkeit</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Verantwortliche Rolle</label>
|
||||
<input type="text" value={policy.responsibleRole} onChange={(e) => set('responsibleRole', e.target.value)}
|
||||
placeholder="z.B. Datenschutzbeauftragter"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Verantwortliche Person</label>
|
||||
<input type="text" value={policy.responsiblePerson} onChange={(e) => set('responsiblePerson', e.target.value)}
|
||||
placeholder="Name der verantwortlichen Person"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Freigabeprozess</label>
|
||||
<textarea value={policy.releaseProcess} onChange={(e) => set('releaseProcess', e.target.value)} rows={3}
|
||||
placeholder="Beschreibung des Freigabeprozesses fuer Loeschungen..."
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sektion 5: VVT-Verknuepfung
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function VVTLinkSection({
|
||||
policy, pid, vvtActivities, updatePolicy,
|
||||
}: {
|
||||
policy: LoeschfristPolicy; pid: string; vvtActivities: any[]
|
||||
updatePolicy: (id: string, updater: (p: LoeschfristPolicy) => LoeschfristPolicy) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6 space-y-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">5. VVT-Verknuepfung</h3>
|
||||
{vvtActivities.length > 0 ? (
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 mb-3">
|
||||
Verknuepfen Sie diese Loeschfrist mit einer Verarbeitungstaetigkeit aus Ihrem VVT.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{policy.linkedVvtIds && policy.linkedVvtIds.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">Verknuepfte Taetigkeiten:</label>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{policy.linkedVvtIds.map((vvtId: string) => {
|
||||
const activity = vvtActivities.find((a: any) => a.id === vvtId)
|
||||
return (
|
||||
<span key={vvtId} className="inline-flex items-center gap-1 bg-blue-100 text-blue-800 text-xs font-medium px-2 py-0.5 rounded-full">
|
||||
{activity?.name || vvtId}
|
||||
<button type="button"
|
||||
onClick={() => updatePolicy(pid, (p) => ({
|
||||
...p, linkedVvtIds: (p.linkedVvtIds || []).filter((id: string) => id !== vvtId),
|
||||
}))}
|
||||
className="text-blue-600 hover:text-blue-900">x</button>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<select
|
||||
onChange={(e) => {
|
||||
const val = e.target.value
|
||||
if (val && !(policy.linkedVvtIds || []).includes(val)) {
|
||||
updatePolicy(pid, (p) => ({ ...p, linkedVvtIds: [...(p.linkedVvtIds || []), val] }))
|
||||
}
|
||||
e.target.value = ''
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500">
|
||||
<option value="">Verarbeitungstaetigkeit verknuepfen...</option>
|
||||
{vvtActivities
|
||||
.filter((a: any) => !(policy.linkedVvtIds || []).includes(a.id))
|
||||
.map((a: any) => (<option key={a.id} value={a.id}>{a.name || a.id}</option>))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-400">
|
||||
Kein VVT gefunden. Erstellen Sie zuerst ein Verarbeitungsverzeichnis, um hier Verknuepfungen herstellen zu koennen.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sektion 6: Review-Einstellungen
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function ReviewSection({ policy, set }: { policy: LoeschfristPolicy; set: SetFn }) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6 space-y-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">6. Review-Einstellungen</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Status</label>
|
||||
<select value={policy.status} onChange={(e) => set('status', e.target.value as PolicyStatus)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500">
|
||||
{Object.entries(STATUS_LABELS).map(([key, label]) => (<option key={key} value={key}>{label}</option>))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Pruefintervall</label>
|
||||
<select value={policy.reviewInterval} onChange={(e) => set('reviewInterval', e.target.value as ReviewInterval)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500">
|
||||
{Object.entries(REVIEW_INTERVAL_LABELS).map(([key, label]) => (<option key={key} value={key}>{label}</option>))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Letzte Pruefung</label>
|
||||
<input type="date" value={policy.lastReviewDate} onChange={(e) => set('lastReviewDate', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Naechste Pruefung</label>
|
||||
<input type="date" value={policy.nextReviewDate} onChange={(e) => set('nextReviewDate', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Tags</label>
|
||||
<TagInput value={policy.tags} onChange={(v) => set('tags', v)} placeholder="Tags hinzufuegen (Enter zum Bestaetigen)" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
170
admin-compliance/app/sdk/loeschfristen/_components/EditorTab.tsx
Normal file
170
admin-compliance/app/sdk/loeschfristen/_components/EditorTab.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import {
|
||||
LoeschfristPolicy, LegalHold, StorageLocation,
|
||||
} from '@/lib/sdk/loeschfristen-types'
|
||||
import { renderStatusBadge } from './UebersichtTab'
|
||||
import {
|
||||
DataObjectSection, DeletionLogicSection, StorageSection,
|
||||
ResponsibilitySection, VVTLinkSection, ReviewSection,
|
||||
} from './EditorSections'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface EditorTabProps {
|
||||
policies: LoeschfristPolicy[]
|
||||
editingId: string | null
|
||||
editingPolicy: LoeschfristPolicy | null
|
||||
vvtActivities: any[]
|
||||
saving: boolean
|
||||
setEditingId: (id: string | null) => void
|
||||
setTab: (tab: 'uebersicht' | 'editor' | 'generator' | 'export') => void
|
||||
updatePolicy: (id: string, updater: (p: LoeschfristPolicy) => LoeschfristPolicy) => void
|
||||
createNewPolicy: () => void
|
||||
deletePolicy: (policyId: string) => void
|
||||
addLegalHold: (policyId: string) => void
|
||||
removeLegalHold: (policyId: string, idx: number) => void
|
||||
addStorageLocation: (policyId: string) => void
|
||||
removeStorageLocation: (policyId: string, idx: number) => void
|
||||
handleSaveAndClose: () => void
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// No-selection view
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function EditorNoSelection({
|
||||
policies, setEditingId, createNewPolicy,
|
||||
}: Pick<EditorTabProps, 'policies' | 'setEditingId' | 'createNewPolicy'>) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-8">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
Loeschfrist zum Bearbeiten waehlen
|
||||
</h3>
|
||||
{policies.length === 0 ? (
|
||||
<p className="text-gray-500">
|
||||
Noch keine Loeschfristen vorhanden.{' '}
|
||||
<button onClick={createNewPolicy}
|
||||
className="text-purple-600 hover:text-purple-800 font-medium underline">
|
||||
Neue Loeschfrist anlegen
|
||||
</button>
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{policies.map((p) => (
|
||||
<button key={p.policyId} onClick={() => setEditingId(p.policyId)}
|
||||
className="w-full text-left px-4 py-3 border border-gray-200 rounded-lg hover:bg-gray-50 transition flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-xs text-gray-400 font-mono mr-2">{p.policyId}</span>
|
||||
<span className="font-medium text-gray-900">{p.dataObjectName || 'Ohne Bezeichnung'}</span>
|
||||
</div>
|
||||
{renderStatusBadge(p.status)}
|
||||
</button>
|
||||
))}
|
||||
<button onClick={createNewPolicy}
|
||||
className="w-full text-left px-4 py-3 border border-dashed border-gray-300 rounded-lg hover:bg-gray-50 transition text-purple-600 font-medium">
|
||||
+ Neue Loeschfrist anlegen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Editor form
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function EditorForm({
|
||||
policy, vvtActivities, saving, setEditingId, setTab,
|
||||
updatePolicy, deletePolicy, addLegalHold, removeLegalHold,
|
||||
addStorageLocation, removeStorageLocation, handleSaveAndClose,
|
||||
}: Omit<EditorTabProps, 'policies' | 'editingId' | 'editingPolicy' | 'createNewPolicy'> & {
|
||||
policy: LoeschfristPolicy
|
||||
}) {
|
||||
const pid = policy.policyId
|
||||
|
||||
const set = <K extends keyof LoeschfristPolicy>(key: K, val: LoeschfristPolicy[K]) => {
|
||||
updatePolicy(pid, (p) => ({ ...p, [key]: val }))
|
||||
}
|
||||
|
||||
const updateLegalHoldItem = (idx: number, updater: (h: LegalHold) => LegalHold) => {
|
||||
updatePolicy(pid, (p) => ({
|
||||
...p, legalHolds: p.legalHolds.map((h, i) => (i === idx ? updater(h) : h)),
|
||||
}))
|
||||
}
|
||||
|
||||
const updateStorageLocationItem = (idx: number, updater: (s: StorageLocation) => StorageLocation) => {
|
||||
updatePolicy(pid, (p) => ({
|
||||
...p, storageLocations: p.storageLocations.map((s, i) => (i === idx ? updater(s) : s)),
|
||||
}))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header with back button */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={() => setEditingId(null)} className="text-gray-400 hover:text-gray-600 transition">
|
||||
← Zurueck
|
||||
</button>
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
{policy.dataObjectName || 'Neue Loeschfrist'}
|
||||
</h2>
|
||||
<span className="text-xs text-gray-400 font-mono">{policy.policyId}</span>
|
||||
</div>
|
||||
{renderStatusBadge(policy.status)}
|
||||
</div>
|
||||
|
||||
<DataObjectSection policy={policy} set={set} />
|
||||
<DeletionLogicSection policy={policy} pid={pid} set={set}
|
||||
updateLegalHoldItem={updateLegalHoldItem} addLegalHold={addLegalHold} removeLegalHold={removeLegalHold} />
|
||||
<StorageSection policy={policy} pid={pid} set={set}
|
||||
updateStorageLocationItem={updateStorageLocationItem}
|
||||
addStorageLocation={addStorageLocation} removeStorageLocation={removeStorageLocation} />
|
||||
<ResponsibilitySection policy={policy} set={set} />
|
||||
<VVTLinkSection policy={policy} pid={pid} vvtActivities={vvtActivities} updatePolicy={updatePolicy} />
|
||||
<ReviewSection policy={policy} set={set} />
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center justify-between bg-white rounded-xl border border-gray-200 p-4">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm('Moechten Sie diese Loeschfrist wirklich loeschen?')) {
|
||||
deletePolicy(pid); setTab('uebersicht')
|
||||
}
|
||||
}}
|
||||
className="text-red-600 hover:text-red-800 font-medium text-sm">
|
||||
Loeschfrist loeschen
|
||||
</button>
|
||||
<div className="flex gap-3">
|
||||
<button onClick={() => { setEditingId(null); setTab('uebersicht') }}
|
||||
className="bg-gray-100 text-gray-700 hover:bg-gray-200 rounded-lg px-4 py-2 font-medium transition">
|
||||
Zurueck zur Uebersicht
|
||||
</button>
|
||||
<button onClick={handleSaveAndClose} disabled={saving}
|
||||
className="bg-purple-600 text-white hover:bg-purple-700 disabled:opacity-50 rounded-lg px-4 py-2 font-medium transition">
|
||||
{saving ? 'Speichern...' : 'Speichern & Schliessen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main export
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function EditorTab(props: EditorTabProps) {
|
||||
if (!props.editingId || !props.editingPolicy) {
|
||||
return (
|
||||
<EditorNoSelection policies={props.policies}
|
||||
setEditingId={props.setEditingId} createNewPolicy={props.createNewPolicy} />
|
||||
)
|
||||
}
|
||||
return <EditorForm {...props} policy={props.editingPolicy} />
|
||||
}
|
||||
261
admin-compliance/app/sdk/loeschfristen/_components/ExportTab.tsx
Normal file
261
admin-compliance/app/sdk/loeschfristen/_components/ExportTab.tsx
Normal file
@@ -0,0 +1,261 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { LoeschfristPolicy } from '@/lib/sdk/loeschfristen-types'
|
||||
import { ComplianceCheckResult } from '@/lib/sdk/loeschfristen-compliance'
|
||||
import {
|
||||
exportPoliciesAsJSON, exportPoliciesAsCSV,
|
||||
generateComplianceSummary, downloadFile,
|
||||
} from '@/lib/sdk/loeschfristen-export'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ExportTabProps {
|
||||
policies: LoeschfristPolicy[]
|
||||
complianceResult: ComplianceCheckResult | null
|
||||
runCompliance: () => void
|
||||
setEditingId: (id: string | null) => void
|
||||
setTab: (tab: 'uebersicht' | 'editor' | 'generator' | 'export') => void
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function ExportTab({
|
||||
policies,
|
||||
complianceResult,
|
||||
runCompliance,
|
||||
setEditingId,
|
||||
setTab,
|
||||
}: ExportTabProps) {
|
||||
const allLegalHolds = policies.flatMap((p) =>
|
||||
p.legalHolds.map((h) => ({
|
||||
...h,
|
||||
policyId: p.policyId,
|
||||
policyName: p.dataObjectName,
|
||||
})),
|
||||
)
|
||||
const activeLegalHolds = allLegalHolds.filter((h) => h.status === 'ACTIVE')
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Compliance Check */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Compliance-Check</h3>
|
||||
<button
|
||||
onClick={runCompliance}
|
||||
disabled={policies.length === 0}
|
||||
className="bg-purple-600 text-white hover:bg-purple-700 rounded-lg px-4 py-2 font-medium transition disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Analyse starten
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{policies.length === 0 && (
|
||||
<p className="text-sm text-gray-400">
|
||||
Erstellen Sie zuerst Loeschfristen, um eine Compliance-Analyse durchzufuehren.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{complianceResult && (
|
||||
<ComplianceResultView
|
||||
complianceResult={complianceResult}
|
||||
setEditingId={setEditingId}
|
||||
setTab={setTab}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Legal Hold Management */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6 space-y-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Legal Hold Verwaltung</h3>
|
||||
|
||||
{allLegalHolds.length === 0 ? (
|
||||
<p className="text-sm text-gray-400">Keine Legal Holds vorhanden.</p>
|
||||
) : (
|
||||
<div>
|
||||
<div className="flex gap-4 mb-4">
|
||||
<div className="text-sm">
|
||||
<span className="font-medium text-gray-700">Gesamt:</span>{' '}
|
||||
<span className="text-gray-900">{allLegalHolds.length}</span>
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
<span className="font-medium text-orange-600">Aktiv:</span>{' '}
|
||||
<span className="text-gray-900">{activeLegalHolds.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-lg">
|
||||
<thead>
|
||||
<tr className="bg-gray-50">
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Loeschfrist</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Bezeichnung</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Grund</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Status</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Erstellt</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{allLegalHolds.map((hold, idx) => (
|
||||
<tr key={idx} className="border-t border-gray-100">
|
||||
<td className="px-3 py-2">
|
||||
<button
|
||||
onClick={() => { setEditingId(hold.policyId); setTab('editor') }}
|
||||
className="text-purple-600 hover:text-purple-800 font-medium text-xs"
|
||||
>
|
||||
{hold.policyName || hold.policyId}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-gray-900">{hold.name || '-'}</td>
|
||||
<td className="px-3 py-2 text-gray-500">{hold.reason || '-'}</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${
|
||||
hold.status === 'ACTIVE'
|
||||
? 'bg-orange-100 text-orange-800'
|
||||
: hold.status === 'RELEASED'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{hold.status === 'ACTIVE' ? 'Aktiv' : hold.status === 'RELEASED' ? 'Aufgehoben' : 'Abgelaufen'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-gray-500 text-xs">{hold.createdAt || '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Export */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6 space-y-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Datenexport</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
Exportieren Sie Ihre Loeschfristen und den Compliance-Status in verschiedenen Formaten.
|
||||
</p>
|
||||
|
||||
{policies.length === 0 ? (
|
||||
<p className="text-sm text-gray-400">
|
||||
Erstellen Sie zuerst Loeschfristen, um Exporte zu generieren.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<button
|
||||
onClick={() => downloadFile(exportPoliciesAsJSON(policies), 'loeschfristen-export.json', 'application/json')}
|
||||
className="bg-gray-100 text-gray-700 hover:bg-gray-200 rounded-lg px-4 py-2 font-medium transition"
|
||||
>
|
||||
JSON Export
|
||||
</button>
|
||||
<button
|
||||
onClick={() => downloadFile(exportPoliciesAsCSV(policies), 'loeschfristen-export.csv', 'text/csv;charset=utf-8')}
|
||||
className="bg-gray-100 text-gray-700 hover:bg-gray-200 rounded-lg px-4 py-2 font-medium transition"
|
||||
>
|
||||
CSV Export
|
||||
</button>
|
||||
<button
|
||||
onClick={() => downloadFile(generateComplianceSummary(policies), 'compliance-bericht.md', 'text/markdown')}
|
||||
className="bg-gray-100 text-gray-700 hover:bg-gray-200 rounded-lg px-4 py-2 font-medium transition"
|
||||
>
|
||||
Compliance-Bericht
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Compliance result sub-component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ComplianceResultView({
|
||||
complianceResult,
|
||||
setEditingId,
|
||||
setTab,
|
||||
}: {
|
||||
complianceResult: ComplianceCheckResult
|
||||
setEditingId: (id: string | null) => void
|
||||
setTab: (tab: 'uebersicht' | 'editor' | 'generator' | 'export') => void
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Score */}
|
||||
<div className="flex items-center gap-4 p-4 rounded-lg bg-gray-50">
|
||||
<div className={`text-4xl font-bold ${
|
||||
complianceResult.score >= 75 ? 'text-green-600'
|
||||
: complianceResult.score >= 50 ? 'text-yellow-600' : 'text-red-600'
|
||||
}`}>
|
||||
{complianceResult.score}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">Compliance-Score</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{complianceResult.score >= 75 ? 'Guter Zustand - wenige Optimierungen noetig'
|
||||
: complianceResult.score >= 50 ? 'Verbesserungsbedarf - wichtige Punkte offen'
|
||||
: 'Kritisch - dringender Handlungsbedarf'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Issues grouped by severity */}
|
||||
{(['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'] as const).map((severity) => {
|
||||
const issues = complianceResult.issues.filter((i) => i.severity === severity)
|
||||
if (issues.length === 0) return null
|
||||
|
||||
const severityConfig = {
|
||||
CRITICAL: { label: 'Kritisch', bg: 'bg-red-50', border: 'border-red-200', text: 'text-red-800', badge: 'bg-red-100 text-red-800' },
|
||||
HIGH: { label: 'Hoch', bg: 'bg-orange-50', border: 'border-orange-200', text: 'text-orange-800', badge: 'bg-orange-100 text-orange-800' },
|
||||
MEDIUM: { label: 'Mittel', bg: 'bg-yellow-50', border: 'border-yellow-200', text: 'text-yellow-800', badge: 'bg-yellow-100 text-yellow-800' },
|
||||
LOW: { label: 'Niedrig', bg: 'bg-blue-50', border: 'border-blue-200', text: 'text-blue-800', badge: 'bg-blue-100 text-blue-800' },
|
||||
}[severity]
|
||||
|
||||
return (
|
||||
<div key={severity}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${severityConfig.badge}`}>
|
||||
{severityConfig.label}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
{issues.length} {issues.length === 1 ? 'Problem' : 'Probleme'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{issues.map((issue, idx) => (
|
||||
<div key={idx} className={`p-3 rounded-lg border ${severityConfig.bg} ${severityConfig.border}`}>
|
||||
<div className={`text-sm font-medium ${severityConfig.text}`}>{issue.title}</div>
|
||||
<p className="text-xs text-gray-600 mt-1">{issue.description}</p>
|
||||
{issue.recommendation && (
|
||||
<p className="text-xs text-gray-500 mt-1 italic">Empfehlung: {issue.recommendation}</p>
|
||||
)}
|
||||
{issue.affectedPolicyId && (
|
||||
<button
|
||||
onClick={() => { setEditingId(issue.affectedPolicyId!); setTab('editor') }}
|
||||
className="text-xs text-purple-600 hover:text-purple-800 font-medium mt-1"
|
||||
>
|
||||
Zur Loeschfrist: {issue.affectedPolicyId}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{complianceResult.issues.length === 0 && (
|
||||
<div className="p-4 rounded-lg bg-green-50 border border-green-200 text-center">
|
||||
<div className="text-green-700 font-medium">Keine Compliance-Probleme gefunden</div>
|
||||
<p className="text-xs text-green-600 mt-1">Alle Loeschfristen entsprechen den Anforderungen.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import {
|
||||
LoeschfristPolicy,
|
||||
RETENTION_DRIVER_META,
|
||||
formatRetentionDuration,
|
||||
getEffectiveDeletionTrigger,
|
||||
} from '@/lib/sdk/loeschfristen-types'
|
||||
import {
|
||||
PROFILING_STEPS, ProfilingAnswer, ProfilingStep,
|
||||
isStepComplete, getProfilingProgress,
|
||||
} from '@/lib/sdk/loeschfristen-profiling'
|
||||
import { renderTriggerBadge } from './UebersichtTab'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface GeneratorTabProps {
|
||||
profilingStep: number
|
||||
setProfilingStep: (s: number | ((prev: number) => number)) => void
|
||||
profilingAnswers: ProfilingAnswer[]
|
||||
handleProfilingAnswer: (stepIndex: number, questionId: string, value: any) => void
|
||||
generatedPolicies: LoeschfristPolicy[]
|
||||
setGeneratedPolicies: (p: LoeschfristPolicy[]) => void
|
||||
selectedGenerated: Set<string>
|
||||
setSelectedGenerated: (s: Set<string>) => void
|
||||
handleGenerate: () => void
|
||||
adoptGeneratedPolicies: (onlySelected: boolean) => void
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Generated policies preview
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function GeneratedPreview({
|
||||
generatedPolicies,
|
||||
selectedGenerated,
|
||||
setSelectedGenerated,
|
||||
setGeneratedPolicies,
|
||||
adoptGeneratedPolicies,
|
||||
}: Pick<
|
||||
GeneratorTabProps,
|
||||
'generatedPolicies' | 'selectedGenerated' | 'setSelectedGenerated' | 'setGeneratedPolicies' | 'adoptGeneratedPolicies'
|
||||
>) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">Generierte Loeschfristen</h3>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
Auf Basis Ihres Profils wurden {generatedPolicies.length} Loeschfristen generiert.
|
||||
Waehlen Sie die relevanten aus und uebernehmen Sie sie.
|
||||
</p>
|
||||
|
||||
<div className="flex gap-3 mb-4">
|
||||
<button
|
||||
onClick={() => setSelectedGenerated(new Set(generatedPolicies.map((p) => p.policyId)))}
|
||||
className="text-sm text-purple-600 hover:text-purple-800 font-medium"
|
||||
>
|
||||
Alle auswaehlen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedGenerated(new Set())}
|
||||
className="text-sm text-gray-500 hover:text-gray-700 font-medium"
|
||||
>
|
||||
Alle abwaehlen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 max-h-[500px] overflow-y-auto">
|
||||
{generatedPolicies.map((gp) => {
|
||||
const selected = selectedGenerated.has(gp.policyId)
|
||||
return (
|
||||
<label
|
||||
key={gp.policyId}
|
||||
className={`flex items-start gap-3 p-4 border rounded-lg cursor-pointer transition ${
|
||||
selected ? 'border-purple-300 bg-purple-50' : 'border-gray-200 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected}
|
||||
onChange={(e) => {
|
||||
const next = new Set(selectedGenerated)
|
||||
if (e.target.checked) next.add(gp.policyId)
|
||||
else next.delete(gp.policyId)
|
||||
setSelectedGenerated(next)
|
||||
}}
|
||||
className="mt-1 text-purple-600 focus:ring-purple-500 rounded"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium text-gray-900">{gp.dataObjectName}</span>
|
||||
<span className="text-xs font-mono text-gray-400">{gp.policyId}</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mb-1">{gp.description}</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{renderTriggerBadge(getEffectiveDeletionTrigger(gp))}
|
||||
<span className="inline-block text-xs font-medium px-2 py-0.5 rounded-full bg-blue-100 text-blue-800">
|
||||
{formatRetentionDuration(gp)}
|
||||
</span>
|
||||
{gp.retentionDriver && (
|
||||
<span className="inline-block text-xs font-medium px-2 py-0.5 rounded-full bg-gray-100 text-gray-600">
|
||||
{RETENTION_DRIVER_META[gp.retentionDriver]?.label || gp.retentionDriver}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between">
|
||||
<button
|
||||
onClick={() => { setGeneratedPolicies([]); setSelectedGenerated(new Set()) }}
|
||||
className="bg-gray-100 text-gray-700 hover:bg-gray-200 rounded-lg px-4 py-2 font-medium transition"
|
||||
>
|
||||
Zurueck zum Profiling
|
||||
</button>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => adoptGeneratedPolicies(false)}
|
||||
className="bg-gray-100 text-gray-700 hover:bg-gray-200 rounded-lg px-4 py-2 font-medium transition"
|
||||
>
|
||||
Alle uebernehmen ({generatedPolicies.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => adoptGeneratedPolicies(true)}
|
||||
disabled={selectedGenerated.size === 0}
|
||||
className="bg-purple-600 text-white hover:bg-purple-700 rounded-lg px-4 py-2 font-medium transition disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Ausgewaehlte uebernehmen ({selectedGenerated.size})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Profiling wizard
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ProfilingWizard({
|
||||
profilingStep,
|
||||
setProfilingStep,
|
||||
profilingAnswers,
|
||||
handleProfilingAnswer,
|
||||
handleGenerate,
|
||||
}: Pick<
|
||||
GeneratorTabProps,
|
||||
'profilingStep' | 'setProfilingStep' | 'profilingAnswers' | 'handleProfilingAnswer' | 'handleGenerate'
|
||||
>) {
|
||||
const totalSteps = PROFILING_STEPS.length
|
||||
const progress = getProfilingProgress(profilingAnswers)
|
||||
const allComplete = PROFILING_STEPS.every((step, idx) =>
|
||||
isStepComplete(step, profilingAnswers.filter((a) => a.stepIndex === idx)),
|
||||
)
|
||||
const currentStep: ProfilingStep | undefined = PROFILING_STEPS[profilingStep]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Progress bar */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Profiling-Assistent</h3>
|
||||
<span className="text-sm text-gray-500">Schritt {profilingStep + 1} von {totalSteps}</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div className="bg-purple-600 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${Math.round(progress * 100)}%` }} />
|
||||
</div>
|
||||
<div className="flex justify-between mt-2">
|
||||
{PROFILING_STEPS.map((step, idx) => (
|
||||
<button key={idx} onClick={() => setProfilingStep(idx)}
|
||||
className={`text-xs font-medium transition ${
|
||||
idx === profilingStep ? 'text-purple-600' : idx < profilingStep ? 'text-green-600' : 'text-gray-400'
|
||||
}`}>
|
||||
{step.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current step questions */}
|
||||
{currentStep && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6 space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-1">{currentStep.title}</h3>
|
||||
{currentStep.description && <p className="text-sm text-gray-500">{currentStep.description}</p>}
|
||||
</div>
|
||||
|
||||
{currentStep.questions.map((question) => {
|
||||
const currentAnswer = profilingAnswers.find(
|
||||
(a) => a.stepIndex === profilingStep && a.questionId === question.id,
|
||||
)
|
||||
return (
|
||||
<div key={question.id} className="border-t border-gray-100 pt-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
{question.label}
|
||||
{question.helpText && (
|
||||
<span className="block text-xs text-gray-400 font-normal mt-0.5">{question.helpText}</span>
|
||||
)}
|
||||
</label>
|
||||
|
||||
{question.type === 'boolean' && (
|
||||
<div className="flex gap-3">
|
||||
{[{ val: true, label: 'Ja' }, { val: false, label: 'Nein' }].map((opt) => (
|
||||
<button key={String(opt.val)}
|
||||
onClick={() => handleProfilingAnswer(profilingStep, question.id, opt.val)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition ${
|
||||
currentAnswer?.value === opt.val
|
||||
? 'bg-purple-600 text-white'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{question.type === 'single' && question.options && (
|
||||
<div className="space-y-2">
|
||||
{question.options.map((opt) => (
|
||||
<label key={opt.value}
|
||||
className={`flex items-center gap-3 p-3 border rounded-lg cursor-pointer transition ${
|
||||
currentAnswer?.value === opt.value ? 'border-purple-300 bg-purple-50' : 'border-gray-200 hover:bg-gray-50'
|
||||
}`}>
|
||||
<input type="radio" name={`${question.id}-${profilingStep}`}
|
||||
checked={currentAnswer?.value === opt.value}
|
||||
onChange={() => handleProfilingAnswer(profilingStep, question.id, opt.value)}
|
||||
className="text-purple-600 focus:ring-purple-500" />
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-900">{opt.label}</span>
|
||||
{opt.description && <span className="block text-xs text-gray-500">{opt.description}</span>}
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{question.type === 'multi' && question.options && (
|
||||
<div className="space-y-2">
|
||||
{question.options.map((opt) => {
|
||||
const selectedValues: string[] = currentAnswer?.value || []
|
||||
const isSelected = selectedValues.includes(opt.value)
|
||||
return (
|
||||
<label key={opt.value}
|
||||
className={`flex items-center gap-3 p-3 border rounded-lg cursor-pointer transition ${
|
||||
isSelected ? 'border-purple-300 bg-purple-50' : 'border-gray-200 hover:bg-gray-50'
|
||||
}`}>
|
||||
<input type="checkbox" checked={isSelected}
|
||||
onChange={(e) => {
|
||||
const next = e.target.checked
|
||||
? [...selectedValues, opt.value]
|
||||
: selectedValues.filter((v) => v !== opt.value)
|
||||
handleProfilingAnswer(profilingStep, question.id, next)
|
||||
}}
|
||||
className="text-purple-600 focus:ring-purple-500 rounded" />
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-900">{opt.label}</span>
|
||||
{opt.description && <span className="block text-xs text-gray-500">{opt.description}</span>}
|
||||
</div>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{question.type === 'number' && (
|
||||
<input type="number" value={currentAnswer?.value ?? ''}
|
||||
onChange={(e) => handleProfilingAnswer(profilingStep, question.id, e.target.value ? parseInt(e.target.value) : '')}
|
||||
min={0} placeholder="Bitte Zahl eingeben"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => setProfilingStep((s: number) => Math.max(0, s - 1))}
|
||||
disabled={profilingStep === 0}
|
||||
className="bg-gray-100 text-gray-700 hover:bg-gray-200 rounded-lg px-4 py-2 font-medium transition disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Zurueck
|
||||
</button>
|
||||
|
||||
{profilingStep < totalSteps - 1 ? (
|
||||
<button
|
||||
onClick={() => setProfilingStep((s: number) => Math.min(totalSteps - 1, s + 1))}
|
||||
className="bg-purple-600 text-white hover:bg-purple-700 rounded-lg px-4 py-2 font-medium transition"
|
||||
>
|
||||
Weiter
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={handleGenerate} disabled={!allComplete}
|
||||
className="bg-purple-600 text-white hover:bg-purple-700 rounded-lg px-5 py-2.5 font-semibold transition disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
Loeschfristen generieren
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main export
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function GeneratorTab(props: GeneratorTabProps) {
|
||||
if (props.generatedPolicies.length > 0) {
|
||||
return <GeneratedPreview {...props} />
|
||||
}
|
||||
return <ProfilingWizard {...props} />
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
|
||||
export function TagInput({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
}: {
|
||||
value: string[]
|
||||
onChange: (v: string[]) => void
|
||||
placeholder?: string
|
||||
}) {
|
||||
const [input, setInput] = useState('')
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter' || e.key === ',') {
|
||||
e.preventDefault()
|
||||
const trimmed = input.trim().replace(/,+$/, '').trim()
|
||||
if (trimmed && !value.includes(trimmed)) {
|
||||
onChange([...value, trimmed])
|
||||
}
|
||||
setInput('')
|
||||
}
|
||||
}
|
||||
|
||||
const remove = (idx: number) => {
|
||||
onChange(value.filter((_, i) => i !== idx))
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-wrap gap-1 mb-1">
|
||||
{value.map((tag, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="inline-flex items-center gap-1 bg-purple-100 text-purple-800 text-xs font-medium px-2 py-0.5 rounded-full"
|
||||
>
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(idx)}
|
||||
className="text-purple-600 hover:text-purple-900"
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder ?? 'Eingabe + Enter'}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 text-sm"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import {
|
||||
LoeschfristPolicy, PolicyStatus, DeletionTriggerLevel,
|
||||
STATUS_COLORS, STATUS_LABELS, TRIGGER_COLORS, TRIGGER_LABELS,
|
||||
RETENTION_DRIVER_META, formatRetentionDuration, isPolicyOverdue,
|
||||
getActiveLegalHolds, getEffectiveDeletionTrigger,
|
||||
} from '@/lib/sdk/loeschfristen-types'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Badge helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function renderStatusBadge(status: PolicyStatus) {
|
||||
const colors = STATUS_COLORS[status] ?? 'bg-gray-100 text-gray-800'
|
||||
const label = STATUS_LABELS[status] ?? status
|
||||
return (
|
||||
<span className={`inline-block text-xs font-semibold px-2 py-0.5 rounded-full ${colors}`}>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function renderTriggerBadge(trigger: DeletionTriggerLevel) {
|
||||
const colors = TRIGGER_COLORS[trigger] ?? 'bg-gray-100 text-gray-800'
|
||||
const label = TRIGGER_LABELS[trigger] ?? trigger
|
||||
return (
|
||||
<span className={`inline-block text-xs font-semibold px-2 py-0.5 rounded-full ${colors}`}>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface UebersichtTabProps {
|
||||
policies: LoeschfristPolicy[]
|
||||
filteredPolicies: LoeschfristPolicy[]
|
||||
stats: { total: number; active: number; draft: number; overdue: number; legalHolds: number }
|
||||
searchQuery: string
|
||||
setSearchQuery: (q: string) => void
|
||||
filter: string
|
||||
setFilter: (f: string) => void
|
||||
driverFilter: string
|
||||
setDriverFilter: (f: string) => void
|
||||
setTab: (tab: 'uebersicht' | 'editor' | 'generator' | 'export') => void
|
||||
setEditingId: (id: string | null) => void
|
||||
createNewPolicy: () => void
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function UebersichtTab({
|
||||
policies,
|
||||
filteredPolicies,
|
||||
stats,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
filter,
|
||||
setFilter,
|
||||
driverFilter,
|
||||
setDriverFilter,
|
||||
setTab,
|
||||
setEditingId,
|
||||
createNewPolicy,
|
||||
}: UebersichtTabProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Stats bar */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||
{[
|
||||
{ label: 'Gesamt', value: stats.total, color: 'text-gray-900' },
|
||||
{ label: 'Aktiv', value: stats.active, color: 'text-green-600' },
|
||||
{ label: 'Entwurf', value: stats.draft, color: 'text-yellow-600' },
|
||||
{ label: 'Pruefung faellig', value: stats.overdue, color: 'text-red-600' },
|
||||
{ label: 'Legal Holds aktiv', value: stats.legalHolds, color: 'text-orange-600' },
|
||||
].map((s) => (
|
||||
<div key={s.label} className="bg-white rounded-xl border border-gray-200 p-6 text-center">
|
||||
<div className={`text-3xl font-bold ${s.color}`}>{s.value}</div>
|
||||
<div className="text-sm text-gray-500 mt-1">{s.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Search & filters */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-4 space-y-3">
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Suche nach Name, ID oder Beschreibung..."
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
<span className="text-sm text-gray-500 font-medium">Status:</span>
|
||||
{[
|
||||
{ key: 'all', label: 'Alle' },
|
||||
{ key: 'active', label: 'Aktiv' },
|
||||
{ key: 'draft', label: 'Entwurf' },
|
||||
{ key: 'review', label: 'Pruefung noetig' },
|
||||
].map((f) => (
|
||||
<button
|
||||
key={f.key}
|
||||
onClick={() => setFilter(f.key)}
|
||||
className={`px-3 py-1 rounded-lg text-sm font-medium transition ${
|
||||
filter === f.key
|
||||
? 'bg-purple-600 text-white'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
<span className="text-sm text-gray-500 font-medium ml-4">Aufbewahrungstreiber:</span>
|
||||
<select
|
||||
value={driverFilter}
|
||||
onChange={(e) => setDriverFilter(e.target.value)}
|
||||
className="px-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
>
|
||||
<option value="all">Alle</option>
|
||||
{Object.entries(RETENTION_DRIVER_META).map(([key, meta]) => (
|
||||
<option key={key} value={key}>{meta.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Policy cards or empty state */}
|
||||
{filteredPolicies.length === 0 && policies.length === 0 ? (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
|
||||
<div className="text-gray-400 text-5xl mb-4">📋</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
Noch keine Loeschfristen angelegt
|
||||
</h3>
|
||||
<p className="text-gray-500 mb-6">
|
||||
Starten Sie den Generator, um auf Basis Ihres Unternehmensprofils
|
||||
automatisch passende Loeschfristen zu erstellen, oder legen Sie
|
||||
manuell eine neue Loeschfrist an.
|
||||
</p>
|
||||
<div className="flex justify-center gap-3">
|
||||
<button
|
||||
onClick={() => setTab('generator')}
|
||||
className="bg-purple-600 text-white hover:bg-purple-700 rounded-lg px-4 py-2 font-medium transition"
|
||||
>
|
||||
Generator starten
|
||||
</button>
|
||||
<button
|
||||
onClick={createNewPolicy}
|
||||
className="bg-gray-100 text-gray-700 hover:bg-gray-200 rounded-lg px-4 py-2 font-medium transition"
|
||||
>
|
||||
Neue Loeschfrist
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : filteredPolicies.length === 0 ? (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-8 text-center">
|
||||
<p className="text-gray-500">Keine Loeschfristen entsprechen den aktuellen Filtern.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{filteredPolicies.map((p) => {
|
||||
const trigger = getEffectiveDeletionTrigger(p)
|
||||
const activeHolds = getActiveLegalHolds(p)
|
||||
const overdue = isPolicyOverdue(p)
|
||||
return (
|
||||
<div
|
||||
key={p.policyId}
|
||||
className="bg-white rounded-xl border border-gray-200 p-6 hover:shadow-md transition relative"
|
||||
>
|
||||
{activeHolds.length > 0 && (
|
||||
<span
|
||||
className="absolute top-3 right-3 text-orange-500"
|
||||
title={`${activeHolds.length} aktive Legal Hold(s)`}
|
||||
>
|
||||
⚠
|
||||
</span>
|
||||
)}
|
||||
<div className="text-xs text-gray-400 font-mono mb-1">{p.policyId}</div>
|
||||
<h3 className="text-base font-semibold text-gray-900 mb-2 truncate">
|
||||
{p.dataObjectName || 'Ohne Bezeichnung'}
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-1.5 mb-3">
|
||||
{renderTriggerBadge(trigger)}
|
||||
<span className="inline-block text-xs font-medium px-2 py-0.5 rounded-full bg-blue-100 text-blue-800">
|
||||
{formatRetentionDuration(p)}
|
||||
</span>
|
||||
{renderStatusBadge(p.status)}
|
||||
{overdue && (
|
||||
<span className="inline-block text-xs font-semibold px-2 py-0.5 rounded-full bg-red-100 text-red-700">
|
||||
Pruefung faellig
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{p.description && (
|
||||
<p className="text-sm text-gray-500 mb-3 line-clamp-2">{p.description}</p>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingId(p.policyId)
|
||||
setTab('editor')
|
||||
}}
|
||||
className="text-sm text-purple-600 hover:text-purple-800 font-medium"
|
||||
>
|
||||
Bearbeiten →
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Floating action button */}
|
||||
{policies.length > 0 && (
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={createNewPolicy}
|
||||
className="bg-purple-600 text-white hover:bg-purple-700 rounded-lg px-5 py-2.5 font-medium transition shadow-sm"
|
||||
>
|
||||
+ Neue Loeschfrist
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
||||
'use client'
|
||||
|
||||
import { getScoreColor } from './helpers'
|
||||
|
||||
export function ComplianceRing({ score, size = 64 }: { score: number; size?: number }) {
|
||||
const strokeWidth = 5
|
||||
const radius = (size - strokeWidth) / 2
|
||||
const circumference = 2 * Math.PI * radius
|
||||
const progress = Math.max(0, Math.min(100, score))
|
||||
const offset = circumference - (progress / 100) * circumference
|
||||
const color = getScoreColor(score)
|
||||
|
||||
return (
|
||||
<div className="relative" style={{ width: size, height: size }}>
|
||||
<svg width={size} height={size} className="transform -rotate-90">
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="#e2e8f0"
|
||||
strokeWidth={strokeWidth}
|
||||
/>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
strokeLinecap="round"
|
||||
className="transition-all duration-700 ease-out"
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-sm font-bold text-slate-900">{score}</span>
|
||||
</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