Compare commits
498 Commits
187dbf1b77
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c7c70b1b1 | ||
|
|
16957cadfd | ||
|
|
3dfe0aa646 | ||
|
|
2e0f13b22c | ||
|
|
9a6c297cd6 | ||
|
|
bb0c7d208c | ||
|
|
7b20e2b006 | ||
|
|
4ff06eca17 | ||
|
|
1c2fdf981d | ||
|
|
a2205abea1 | ||
|
|
ef7742cd44 | ||
|
|
3fe0fc853c | ||
|
|
8f2cc3b93b | ||
|
|
753b8f32c7 | ||
|
|
390d32a9cb | ||
|
|
fc8b6445f3 | ||
|
|
717c31547a | ||
|
|
55a2cd4a3d | ||
|
|
6fcf7c13d7 | ||
|
|
b1300ade3e | ||
|
|
5d53acf5dc | ||
|
|
f8fd329059 | ||
|
|
1ac716261c | ||
|
|
01bf1463b8 | ||
|
|
cc6f1489a3 | ||
|
|
b47d351c73 | ||
| 5231490ccc | |||
|
|
f96536ebbe | ||
|
|
c05a71163b | ||
|
|
71a4a3d7f3 | ||
|
|
5e7d5d0a18 | ||
|
|
391aab83e0 | ||
|
|
8ec8af4c2d | ||
|
|
8266c37911 | ||
|
|
baf2d8a550 | ||
|
|
04d78d5fcd | ||
|
|
c41607595e | ||
|
|
58f108b578 | ||
|
|
f7a5f9e1ed | ||
|
|
3f1444541f | ||
|
|
13f57c4519 | ||
|
|
3f2aff2389 | ||
|
|
3fb5b94905 | ||
|
|
c293d76e6b | ||
|
|
e0b3c54212 | ||
|
|
a83056b5e7 | ||
|
|
9f96061631 | ||
|
|
3f306fb6f0 | ||
|
|
9ec72ed681 | ||
|
|
a7fe32fb82 | ||
|
|
9ecd3b2d84 | ||
|
|
19d6437161 | ||
|
|
7d8e5667c9 | ||
|
|
feedeb052f | ||
|
|
92a47bf6f9 | ||
|
|
788172e869 | ||
|
|
91063f09b8 | ||
|
|
b00fe6cb73 | ||
|
|
d32ad81094 | ||
|
|
e3a1822883 | ||
|
|
083792dfd7 | ||
|
|
aabfd0aecd | ||
|
|
11f13b3f74 | ||
|
|
20fbfc197e | ||
|
|
b5d20a4c1d | ||
|
|
54add75eb0 | ||
|
|
ce27636b67 | ||
|
|
e58af8aa30 | ||
|
|
ada50f0466 | ||
|
|
c34f8528a7 | ||
|
|
ad6e6019e9 | ||
|
|
535d3d8c20 | ||
|
|
90d14eb546 | ||
|
|
0125199c76 | ||
|
|
cfd4fc347f | ||
|
|
2adbacf267 | ||
|
|
3180457f20 | ||
|
|
9d96330a54 | ||
|
|
c50e57fd85 | ||
|
|
af08f089df | ||
|
|
1fcd8244b1 | ||
|
|
e0c1d21879 | ||
|
|
2ade65431a | ||
|
|
c43d9da6d0 | ||
|
|
e04816cfe5 | ||
|
|
519ffdc8dc | ||
|
|
653fa07f57 | ||
|
|
87f2ce9692 | ||
|
|
8044ddb776 | ||
|
|
7907b3f25b | ||
|
|
9096aad693 | ||
|
|
9f2224efc4 | ||
|
|
ffae41237e | ||
|
|
1c1af4e38d | ||
|
|
92a730626d | ||
|
|
cc3a9a37dc | ||
|
|
e6ff76d0e1 | ||
|
|
824b1be6a4 | ||
|
|
554320770a | ||
|
|
eeb9931d87 | ||
|
|
76962a2831 | ||
|
|
4921d1c052 | ||
|
|
6571b580dc | ||
|
|
d5287f4bdd | ||
|
|
82a5a388b8 | ||
|
|
637eb012f5 | ||
|
|
2b818c6fb3 | ||
|
|
3a22a2fa52 | ||
|
|
062e827801 | ||
|
|
f404226d6e | ||
|
|
8dfab4ba14 | ||
|
|
5c1a514b52 | ||
|
|
e091bbc855 | ||
|
|
ff4c359d46 | ||
|
|
f169b13dbf | ||
|
|
42d0c7b1fc | ||
|
|
4fcb842a92 | ||
|
|
38d3d24121 | ||
|
|
dd64e33e88 | ||
|
|
2f8269d115 | ||
|
|
532febe35c | ||
|
|
0a0863f31c | ||
|
|
d892ad161f | ||
|
|
17153ccbe8 | ||
|
|
352d7112c9 | ||
|
|
0957254547 | ||
|
|
f17608a956 | ||
|
|
ce3df9f080 | ||
|
|
375b34a0d8 | ||
|
|
2da39e035d | ||
|
|
1989c410a9 | ||
|
|
c55a6ab995 | ||
|
|
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 | ||
|
|
bc75b4455d | ||
|
|
712fa8cb74 | ||
|
|
447ec08509 | ||
|
|
8cb1dc1108 | ||
|
|
f8d9919b97 | ||
|
|
fb2cf29b34 | ||
|
|
f39e5a71af | ||
|
|
ac42a0aaa0 | ||
|
|
52e463a7c8 | ||
|
|
2dee62fa6f | ||
|
|
3fb07e201f | ||
|
|
81c9ce5de3 | ||
|
|
db7c207464 | ||
|
|
cb034b8009 | ||
|
|
564f93259b | ||
|
|
89ac223c41 | ||
|
|
23dd5116b3 | ||
|
|
81ce9dde07 | ||
|
|
5e9cab6ab5 | ||
|
|
a29bfdd588 | ||
|
|
9dbb4cc5d2 | ||
|
|
c56bccaedf | ||
|
|
230fbeb490 | ||
|
|
6d3bdf8e74 | ||
|
|
200facda6a | ||
|
|
9282850138 | ||
|
|
770f0b5ab0 | ||
|
|
35784c35eb | ||
|
|
cce2707c03 | ||
|
|
2efc738803 | ||
|
|
e6201d5239 | ||
|
|
48ca0a6bef | ||
|
|
1a63f5857b | ||
|
|
295c18c6f7 | ||
|
|
649a3c5e4e | ||
|
|
bdd2f6fa0f | ||
|
|
ac6134ce6d | ||
|
|
0027f78fc5 | ||
|
|
b29a7caee7 | ||
|
|
a14e2f3a00 | ||
|
|
71b8c33270 | ||
|
|
f2924a58ed | ||
|
|
643b26618f | ||
|
|
c52dbdb8f1 | ||
|
|
c3a53fe5d2 | ||
|
|
df5b6d69ef | ||
|
|
4f6ac9b23a | ||
|
|
5ea31a3236 | ||
|
|
95c371e9a5 | ||
|
|
b1627252ee | ||
|
|
2a0449c9b7 | ||
|
|
92d37a1660 | ||
|
|
0e16640c28 | ||
|
|
24f02b52ed | ||
|
|
9b0f25c105 | ||
|
|
1cc34c23d9 | ||
|
|
5dd7a27336 | ||
|
|
c3afa628ed | ||
|
|
4b1eede45b | ||
|
|
2a70441eaa | ||
|
|
f2819b99af | ||
|
|
3bb9fffab6 | ||
|
|
148c7ba3af | ||
|
|
a9e0869205 | ||
|
|
653aad57e3 | ||
|
|
a7f7e57dd7 | ||
|
|
567e82ddf5 | ||
|
|
36ef34169a | ||
|
|
d22c47c9eb | ||
|
|
825e070ed9 | ||
|
|
4f6bc8f6f6 | ||
|
|
d2133dbfa2 | ||
|
|
6d2de9b897 | ||
|
|
5adb1c5f16 | ||
|
|
9c1355c05f | ||
|
|
3b2006ebce | ||
|
|
c7651796c9 | ||
|
|
c8fd9cc780 | ||
|
|
0d95c3bb44 | ||
|
|
f066cf1a03 | ||
|
|
dd09fa7a46 | ||
|
|
f3e05c1bf7 | ||
|
|
2ed1c08acf | ||
|
|
4018b9af9b | ||
|
|
a9f291ff49 | ||
|
|
0171d611f6 | ||
|
|
637fab6fdb | ||
|
|
d462141ccd | ||
|
|
5f8aebf5b1 | ||
|
|
c74f506415 | ||
|
|
49ce417428 | ||
|
|
13d13c8226 | ||
|
|
b6e6ffaaee | ||
|
|
8a05fcc2f0 | ||
|
|
9812ff46f3 | ||
|
|
30236c0001 | ||
|
|
b4d2be83eb | ||
|
|
38c7cf0a00 | ||
|
|
399fa62267 | ||
| f1710fdb9e | |||
|
|
1dfea51919 | ||
|
|
559d7960a2 | ||
|
|
a101426dba | ||
|
|
f6b22820ce | ||
|
|
86588aff09 | ||
|
|
033fa52e5b | ||
|
|
005fb9d219 | ||
|
|
0c01f1c96c | ||
|
|
ffd256d420 | ||
|
|
d542dbbacd | ||
|
|
a3d0024d39 | ||
|
|
998d427c3c | ||
|
|
99f3180ffc | ||
|
|
2ec340c64b | ||
|
|
499ddc04d5 | ||
|
|
f738ca8c52 | ||
|
|
c530898963 | ||
|
|
cdafc4d9f4 | ||
|
|
de19ef0684 | ||
|
|
c87f07c99a | ||
|
|
453eec9ed8 | ||
|
|
050f353192 | ||
|
|
8442115e7c | ||
|
|
999cc81c78 | ||
|
|
ff66612beb | ||
|
|
42ec3cad6d | ||
|
|
9945a62a50 | ||
|
|
eef1c2e7d3 | ||
|
|
a0e2a35e66 | ||
|
|
57f390190d | ||
|
|
cf60c39658 | ||
|
|
c88653b221 | ||
|
|
87d06c8b20 | ||
|
|
0b47612272 | ||
|
|
c14b31b3bc | ||
|
|
0b836f7e2d | ||
|
|
18d9eec654 | ||
|
|
339505feed | ||
|
|
23b9808bf3 | ||
|
|
c3654bc9ea | ||
|
|
363bf9606a | ||
|
|
e88c0aeeb3 | ||
|
|
ebe7e90bd8 | ||
|
|
995de9e0f4 | ||
|
|
4e08364bc6 | ||
|
|
7f38df9d9c | ||
|
|
cb48b8289e | ||
|
|
46048554cb | ||
|
|
a673cb0ce4 | ||
|
|
7afbcfd9f5 | ||
|
|
091f093e1b | ||
|
|
5d99d5d47a | ||
|
|
e3a877b549 | ||
|
|
237c05a94c | ||
|
|
b1a0dd3615 | ||
|
|
6e7d0d9b14 | ||
|
|
24afed69c1 | ||
|
|
579fe1b5e1 | ||
|
|
ee6743c7c6 | ||
|
|
a6818b39c5 | ||
|
|
e3fb81fc0d | ||
|
|
3512963006 | ||
|
|
85b3cc3421 | ||
|
|
f6019ecba9 | ||
|
|
1f91e05600 | ||
|
|
3c0c1e49da | ||
|
|
5da93c5d10 | ||
|
|
fa4cda7627 | ||
|
|
90d99bba08 | ||
|
|
2c35775b44 | ||
|
|
aaf95cf894 | ||
|
|
9f41ed4f8e | ||
|
|
e7fab73a3a | ||
|
|
f8917ee6fd | ||
|
|
51a208a2e1 | ||
|
|
1c59996f32 | ||
|
|
61064fdcba | ||
|
|
11d4c2fd36 | ||
|
|
3d22935065 | ||
|
|
09cfb79840 | ||
|
|
d53cf21b95 | ||
|
|
0c83e765d9 | ||
|
|
9b59044663 | ||
|
|
d787e58341 | ||
|
|
4b778f2f85 | ||
|
|
0affa4eb66 | ||
|
|
d3fc4cdaaa | ||
|
|
de486aeab0 | ||
|
|
e96f623af0 | ||
|
|
53ff0722a4 | ||
|
|
2abf0b4cac | ||
|
|
fd45545fbe | ||
|
|
504b1a1207 | ||
|
|
66d32cd744 | ||
|
|
7fa0349fe4 | ||
|
|
6ff9f1f2f3 | ||
|
|
56758e8b55 | ||
|
|
0c75182fb3 | ||
|
|
4a60b3a744 | ||
|
|
95fcba34cd | ||
|
|
6509e64dd9 | ||
|
|
7ec6b9f6c0 | ||
|
|
9e65dff7d6 | ||
|
|
1e84df9769 | ||
|
|
ef9aed666f | ||
|
|
37166c966f | ||
|
|
3467bce222 | ||
|
|
a5e4801b09 | ||
|
|
8f3fb84b61 | ||
|
|
2dd86e97be | ||
|
|
8742cb7f5a | ||
|
|
6a940344c2 | ||
|
|
095eff26d9 | ||
|
|
3593a4ff78 | ||
|
|
4cbfea5c1d | ||
|
|
885b97d422 | ||
|
|
ee359885a8 | ||
|
|
4d2f4f2d24 | ||
|
|
ec4ed1f2ad | ||
|
|
960b8e757c | ||
|
|
adc95267bd | ||
|
|
b5625c14aa | ||
|
|
4a564ad8f7 | ||
|
|
d527fcbdc8 | ||
|
|
d212208587 | ||
|
|
a1980cd12d | ||
|
|
35576fb6f8 | ||
|
|
529c37d91a | ||
|
|
efeacc1619 | ||
|
|
3ed8300daf | ||
|
|
f3ccfe5dcd | ||
|
|
38e278ee3c | ||
|
|
2540a2189a | ||
|
|
bd9796725a | ||
|
|
a181c977c3 | ||
|
|
ef17151a41 | ||
|
|
3707ffe799 | ||
|
|
3913931d5b | ||
|
|
0503e72a80 | ||
|
|
6ad7d62369 | ||
|
|
789c215e5e | ||
|
|
ff765b2d71 | ||
|
|
308d559c85 | ||
|
|
274dc68e24 | ||
|
|
6e0e9cd3cf | ||
|
|
451616b10e | ||
|
|
b7c1a5da1a | ||
|
|
2211cb9349 | ||
|
|
6a8289246c | ||
|
|
93c200626c | ||
|
|
b4d39b9709 | ||
|
|
05aa0ee2c6 | ||
|
|
1454427872 | ||
|
|
a9de7b4010 | ||
|
|
a694b9d9ea | ||
|
|
dc0d38ea40 | ||
|
|
832c177688 | ||
|
|
560bdfb7fd | ||
|
|
dd404da6cd | ||
|
|
f0357ee473 | ||
|
|
e0f7f2134e | ||
|
|
7f3bf93cd6 | ||
|
|
c1a1ce8b71 | ||
|
|
b9ac4fbb75 | ||
|
|
94b6b2b05b | ||
|
|
f82d954355 | ||
|
|
1c5a4c2d96 | ||
|
|
3dbbebb827 | ||
|
|
1d9972b565 | ||
|
|
076cdd587d | ||
|
|
0fc3e7754f | ||
|
|
eca0855216 | ||
|
|
9f0791802b | ||
|
|
215b95adfa | ||
|
|
7e5047290c | ||
|
|
87dc22500d | ||
|
|
b298cc55d0 | ||
|
|
b8fa4429c4 | ||
|
|
533e0d85f4 | ||
|
|
119689ee9e | ||
|
|
10e1bf45ae | ||
|
|
d454acceff | ||
|
|
f909182632 | ||
|
|
29e6998a28 | ||
|
|
7a55955439 | ||
|
|
30bccfa39a | ||
|
|
d3740ac445 | ||
|
|
25d5da78ef | ||
|
|
9143b84daa | ||
|
|
a4df3201db | ||
|
|
312c2c9b60 | ||
|
|
d4845adea7 | ||
|
|
232997deb6 | ||
|
|
b19fc11737 | ||
|
|
79b423e549 | ||
|
|
393eab6acd | ||
|
|
f14d906f70 | ||
|
|
c0b179510d | ||
|
|
3570dd10ea | ||
|
|
9fa1d5e91e | ||
|
|
113ecdfa77 | ||
|
|
799668e472 | ||
|
|
5c7c0055ff | ||
|
|
ec53ba0350 | ||
|
|
34fc8dc654 | ||
|
|
7cc420bd9e | ||
|
|
d48ebc5211 | ||
|
|
d079886819 | ||
|
|
fc83ebfd82 | ||
|
|
a50a9810ee | ||
|
|
f7a0b11e41 | ||
|
|
80a988dc58 | ||
|
|
e6d666b89b | ||
|
|
cd15ab0932 | ||
|
|
d9f819e5be | ||
|
|
14a99322eb | ||
|
|
3d9bc285ac | ||
|
|
a228b3b528 |
@@ -1,68 +1,167 @@
|
||||
# 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`.
|
||||
|
||||
|
||||
|
||||
## First-Time Setup & Claude Code Onboarding
|
||||
|
||||
**For humans:** Read this CLAUDE.md top to bottom before your first commit. Then read `AGENTS.<lang>.md` for the service you are working on (`AGENTS.python.md`, `AGENTS.go.md`, or `AGENTS.typescript.md`).
|
||||
|
||||
**For Claude Code sessions — things that cause first-commit failures:**
|
||||
|
||||
1. **Wrong branch.** Never commit directly to `main`. Create a feature branch first: `git checkout -b feat/my-change`.
|
||||
|
||||
2. **PreToolUse hook blocks your write.** The `PreToolUse` hooks in `.claude/settings.json` will reject Write/Edit operations on any file that would push its line count past 500. This is intentional — split the file into smaller modules instead of trying to bypass the hook.
|
||||
|
||||
3. **Missing `[guardrail-change]` marker.** The `guardrail-integrity` CI job fails if you modify a guardrail file without the marker in the commit message body. See the table below.
|
||||
|
||||
4. **Never `git add -A` or `git add .`.** Stage files individually by path. `git add -A` risks committing `.env`, `node_modules/`, `.next/`, compiled binaries, and other artifacts that must never enter the repo.
|
||||
|
||||
5. **LOC check before push.** After any session, run `bash scripts/check-loc.sh`. It must exit 0 before you push. The git pre-commit hook runs this automatically, but run it manually first to catch issues early.
|
||||
|
||||
### Commit message quick reference
|
||||
|
||||
| Marker | Required when touching |
|
||||
|--------|----------------------|
|
||||
| `[guardrail-change]` | `.claude/settings.json`, `scripts/check-loc.sh`, `scripts/githooks/pre-commit`, `.claude/rules/loc-exceptions.txt`, any `AGENTS.*.md` |
|
||||
| `[migration-approved]` | Anything under `migrations/` or `alembic/versions/` |
|
||||
|
||||
Add the marker anywhere in the commit message body or footer — the CI job does a plain-text grep for it.
|
||||
|
||||
---
|
||||
|
||||
## Entwicklungsumgebung (WICHTIG - IMMER ZUERST LESEN)
|
||||
|
||||
### Zwei-Rechner-Setup
|
||||
### Zwei-Rechner-Setup + Orca
|
||||
|
||||
| Geraet | Rolle | Aufgaben |
|
||||
|--------|-------|----------|
|
||||
| **MacBook** | Entwicklung | Claude Terminal, Code-Entwicklung, Browser (Frontend-Tests) |
|
||||
| **Mac Mini** | Server | Docker, alle Services, Tests, Builds, Deployment |
|
||||
| **Mac Mini** | Lokaler Server | Docker fuer lokale Dev/Tests (NICHT fuer Production!) |
|
||||
| **Orca** | Production | Automatisches Build + Deploy bei Push auf origin |
|
||||
|
||||
**WICHTIG:** Code wird direkt auf dem MacBook in diesem Repo bearbeitet. Docker und Services laufen auf dem Mac Mini.
|
||||
**WICHTIG:** Code wird auf dem MacBook bearbeitet. Production-Deployment laeuft automatisch ueber Orca.
|
||||
|
||||
### Entwicklungsworkflow
|
||||
### Entwicklungsworkflow (CI/CD — Orca)
|
||||
|
||||
```bash
|
||||
# 1. Code auf MacBook bearbeiten (dieses Verzeichnis)
|
||||
# 2. Committen und pushen:
|
||||
git push origin main && git push gitea main
|
||||
git push origin main
|
||||
|
||||
# 3. Auf Mac Mini pullen und Container neu bauen:
|
||||
ssh macmini "cd /Users/benjaminadmin/Projekte/breakpilot-compliance && git pull --no-rebase origin main"
|
||||
ssh macmini "cd /Users/benjaminadmin/Projekte/breakpilot-compliance && /usr/local/bin/docker compose build --no-cache <service> && /usr/local/bin/docker compose up -d <service>"
|
||||
# 3. FERTIG! Push auf origin triggert automatisch:
|
||||
# - Gitea Actions: Lint → Tests → Validierung
|
||||
# - Orca: Build → Deploy
|
||||
# Dauer: ca. 3 Minuten
|
||||
# Status pruefen: https://gitea.meghsakha.com/Benjamin_Boenisch/breakpilot-compliance/actions
|
||||
```
|
||||
|
||||
**NICHT MEHR NOETIG:** Manuelles `ssh macmini "docker compose build"` fuer Production.
|
||||
**NIEMALS** manuell in Orca auf "Redeploy" klicken — Gitea Actions triggert Orca automatisch.
|
||||
|
||||
### Post-Push Deploy-Monitoring (PFLICHT nach jedem Push)
|
||||
|
||||
**IMMER wenn Claude auf origin pusht, MUSS danach automatisch das Deploy-Monitoring laufen:**
|
||||
|
||||
1. Dem User sofort mitteilen: "Deploy gestartet, ich ueberwache den Status..."
|
||||
2. Im Hintergrund Health-Checks pollen (alle 20 Sekunden, max 5 Minuten):
|
||||
```bash
|
||||
# Compliance Health-Endpoints:
|
||||
curl -sf https://api-dev.breakpilot.ai/health # Backend Compliance
|
||||
curl -sf https://sdk-dev.breakpilot.ai/health # AI Compliance SDK
|
||||
```
|
||||
3. Sobald ALLE Endpoints healthy sind, dem User im Chat melden:
|
||||
**"Deploy abgeschlossen! Du kannst jetzt testen: https://admin-dev.breakpilot.ai"**
|
||||
4. Falls nach 5 Minuten noch nicht healthy → Fehlermeldung mit Hinweis auf Orca-Logs.
|
||||
|
||||
**Ablauf im Terminal:**
|
||||
```
|
||||
> git push origin main ✓
|
||||
> "Deploy gestartet, ich ueberwache den Status..."
|
||||
> [Hintergrund-Polling laeuft]
|
||||
> "Deploy abgeschlossen! Alle Services healthy. Du kannst jetzt testen."
|
||||
```
|
||||
|
||||
### CI/CD Pipeline (Gitea Actions → Orca)
|
||||
|
||||
```
|
||||
Push auf origin main → go-lint/python-lint/nodejs-lint (nur PRs)
|
||||
→ test-go-ai-compliance
|
||||
→ test-python-backend-compliance
|
||||
→ test-python-document-crawler
|
||||
→ test-python-dsms-gateway
|
||||
→ validate-canonical-controls
|
||||
→ Orca: Build + Deploy (automatisch bei Push)
|
||||
```
|
||||
|
||||
**Dateien:**
|
||||
- `.gitea/workflows/ci.yaml` — Pipeline-Definition (Tests + Validierung)
|
||||
- `docker-compose.yml` — Haupt-Compose
|
||||
- `docker-compose.hetzner.yml` — Override: arm64→amd64 fuer Orca Production (x86_64)
|
||||
|
||||
### Lokale Entwicklung (Mac Mini — optional)
|
||||
|
||||
```bash
|
||||
# Nur fuer lokale Tests, NICHT fuer Production:
|
||||
ssh macmini "git -C /Users/benjaminadmin/Projekte/breakpilot-compliance pull --no-rebase origin main"
|
||||
ssh macmini "/usr/local/bin/docker compose -f /Users/benjaminadmin/Projekte/breakpilot-compliance/docker-compose.yml build --no-cache <service>"
|
||||
ssh macmini "/usr/local/bin/docker compose -f /Users/benjaminadmin/Projekte/breakpilot-compliance/docker-compose.yml up -d <service>"
|
||||
|
||||
# Fuer schnelle Iteration ohne Commit (rsync):
|
||||
rsync -avz --exclude node_modules --exclude .next --exclude .git \
|
||||
admin-compliance/ macmini:~/Projekte/breakpilot-compliance/admin-compliance/
|
||||
```
|
||||
|
||||
### SSH-Verbindung (fuer Docker/Tests)
|
||||
|
||||
```bash
|
||||
ssh macmini "cd /Users/benjaminadmin/Projekte/breakpilot-compliance && <cmd>"
|
||||
```
|
||||
**WICHTIG:** Docker-Pfad auf Mac Mini ist `/usr/local/bin/docker` (nicht im Standard-SSH-PATH).
|
||||
**WICHTIG:** `cd` funktioniert NICHT in SSH-Einzelbefehlen — immer `-f <pfad>/docker-compose.yml` verwenden!
|
||||
|
||||
---
|
||||
|
||||
## Voraussetzung
|
||||
|
||||
**breakpilot-core MUSS laufen!** Dieses Projekt nutzt Core-Services:
|
||||
- PostgreSQL (Schema: `compliance`, `core`)
|
||||
- Valkey (Session-Cache)
|
||||
- Vault (Secrets)
|
||||
- RAG-Service (Vektorsuche fuer Compliance-Dokumente)
|
||||
- Nginx (Reverse Proxy)
|
||||
|
||||
Pruefen: `curl -sf http://macmini:8099/health`
|
||||
**Externe Services (Production):**
|
||||
- PostgreSQL 17 (sslmode=require) — Schemas: `compliance`, `public`
|
||||
- Qdrant @ `qdrant-dev.breakpilot.ai` (HTTPS, API-Key)
|
||||
- Object Storage (S3-kompatibel, TLS)
|
||||
|
||||
Config via `.env` (nicht im Repo): `COMPLIANCE_DATABASE_URL`, `QDRANT_URL`, `QDRANT_API_KEY`
|
||||
|
||||
---
|
||||
|
||||
## Haupt-URLs (Browser auf MacBook)
|
||||
## Haupt-URLs
|
||||
|
||||
### Frontends
|
||||
### Production (Orca-deployed)
|
||||
|
||||
| URL | Service | Beschreibung |
|
||||
|-----|---------|--------------|
|
||||
| **https://macmini:3007/** | Admin Compliance | SDK-Dashboard, alle Compliance-Module |
|
||||
| **https://macmini:3006/** | Developer Portal | API-Dokumentation fuer Kunden |
|
||||
| **https://admin-dev.breakpilot.ai/** | Admin Compliance | SDK-Dashboard, alle Compliance-Module |
|
||||
| **https://developers-dev.breakpilot.ai/** | Developer Portal | API-Dokumentation fuer Kunden |
|
||||
| https://api-dev.breakpilot.ai/ | Backend Compliance | Compliance APIs (DSGVO, DSR, GDPR) |
|
||||
| https://sdk-dev.breakpilot.ai/ | AI Compliance SDK | KI-konforme Compliance-Analyse |
|
||||
|
||||
### Backend-APIs
|
||||
### Lokal (Mac Mini — nur Dev/Tests)
|
||||
|
||||
| URL | Service | Beschreibung |
|
||||
|-----|---------|--------------|
|
||||
| https://macmini:8002/ | Backend Compliance | Compliance APIs (DSGVO, DSR, GDPR) |
|
||||
| https://macmini:8093/ | AI Compliance SDK | KI-konforme Compliance-Analyse |
|
||||
| https://macmini:3007/ | Admin Compliance | Lokale Entwicklung |
|
||||
| https://macmini:3006/ | Developer Portal | Lokale Entwicklung |
|
||||
| https://macmini:8002/ | Backend Compliance | Lokale Entwicklung |
|
||||
| https://macmini:8093/ | AI Compliance SDK | Lokale Entwicklung |
|
||||
|
||||
### Admin Compliance Module (https://macmini:3007/)
|
||||
|
||||
@@ -87,18 +186,20 @@ Pruefen: `curl -sf http://macmini:8099/health`
|
||||
|
||||
---
|
||||
|
||||
## Services (~8 Container)
|
||||
## Services (10 Container)
|
||||
|
||||
| Service | Tech | Port | Container |
|
||||
|---------|------|------|-----------|
|
||||
| admin-compliance | Next.js 15 | 3007 (via nginx) | bp-compliance-admin |
|
||||
| backend-compliance | Python/FastAPI | 8002 | bp-compliance-backend |
|
||||
| ai-compliance-sdk | Python/FastAPI | 8093 | bp-compliance-ai-sdk |
|
||||
| developer-portal | Next.js | 3006 (via nginx) | bp-compliance-developer-portal |
|
||||
| dsms-node | Node.js | 4001/5001 | bp-compliance-dsms-node |
|
||||
| dsms-gateway | Node.js | 8085 | bp-compliance-dsms-gateway |
|
||||
| pca-platform | Python | - | bp-compliance-pca |
|
||||
| consent-sdk | Node.js | - | bp-compliance-consent-sdk |
|
||||
| ai-compliance-sdk | Go/Gin | 8090→8093 | bp-compliance-ai-sdk |
|
||||
| developer-portal | Next.js 15 | 3006 (via nginx) | bp-compliance-developer-portal |
|
||||
| compliance-tts-service | Python/Piper TTS | 8095 | bp-compliance-tts |
|
||||
| document-crawler | Python/FastAPI | 8098 | bp-compliance-document-crawler |
|
||||
| dsms-node | IPFS Kubo | 4001/5001/8085 | bp-compliance-dsms-node |
|
||||
| dsms-gateway | Node.js | 8082 | bp-compliance-dsms-gateway |
|
||||
| docs | MkDocs/nginx | 8011 | bp-compliance-docs |
|
||||
| core-wait | curl health-check | - | bp-compliance-core-wait |
|
||||
|
||||
### Docker-Netzwerk
|
||||
Nutzt das externe Core-Netzwerk:
|
||||
@@ -144,41 +245,50 @@ breakpilot-compliance/
|
||||
├── dsms-node/ # IPFS Node
|
||||
├── dsms-gateway/ # IPFS Gateway
|
||||
├── scripts/ # Helper Scripts
|
||||
└── docker-compose.yml # Compliance Compose (~8 Services)
|
||||
├── docker-compose.yml # Compliance Compose (~10 Services, platform: arm64)
|
||||
├── docker-compose.hetzner.yml # Override: arm64→amd64 fuer Orca Production
|
||||
└── .gitea/workflows/ci.yaml # CI/CD Pipeline (Lint → Tests → Validierung)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Haeufige Befehle
|
||||
|
||||
### Docker
|
||||
### Deployment (CI/CD — Standardweg)
|
||||
|
||||
```bash
|
||||
# Compliance-Services starten (Core muss laufen!)
|
||||
ssh macmini "cd /Users/benjaminadmin/Projekte/breakpilot-compliance && /usr/local/bin/docker compose up -d"
|
||||
# Committen und pushen → Orca deployt automatisch:
|
||||
git push origin main
|
||||
|
||||
# Einzelnen Service neu bauen
|
||||
ssh macmini "cd /Users/benjaminadmin/Projekte/breakpilot-compliance && /usr/local/bin/docker compose build --no-cache <service>"
|
||||
# CI-Status pruefen (im Browser):
|
||||
# https://gitea.meghsakha.com/Benjamin_Boenisch/breakpilot-compliance/actions
|
||||
|
||||
# Health Checks:
|
||||
curl -sf https://api-dev.breakpilot.ai/health
|
||||
curl -sf https://sdk-dev.breakpilot.ai/health
|
||||
```
|
||||
|
||||
### Git
|
||||
|
||||
```bash
|
||||
git push origin main
|
||||
|
||||
# Remote:
|
||||
# origin: ssh://git@gitea.meghsakha.com:22222/Benjamin_Boenisch/breakpilot-compliance.git
|
||||
```
|
||||
|
||||
### Lokale Docker-Befehle (Mac Mini — nur fuer Dev/Tests)
|
||||
|
||||
```bash
|
||||
# Logs
|
||||
ssh macmini "/usr/local/bin/docker logs -f bp-compliance-<service>"
|
||||
|
||||
# Status
|
||||
ssh macmini "/usr/local/bin/docker ps --filter name=bp-compliance"
|
||||
```
|
||||
|
||||
**WICHTIG:** Docker-Pfad auf Mac Mini ist `/usr/local/bin/docker` (nicht im Standard-SSH-PATH).
|
||||
|
||||
### Git
|
||||
|
||||
```bash
|
||||
# Zu BEIDEN Remotes pushen (PFLICHT!):
|
||||
ssh macmini "cd /Users/benjaminadmin/Projekte/breakpilot-compliance && git push all main"
|
||||
|
||||
# Remotes:
|
||||
# origin: lokale Gitea (macmini:3003)
|
||||
# gitea: gitea.meghsakha.com
|
||||
# all: beide gleichzeitig
|
||||
# Lokaler Rebuild (nur wenn noetig):
|
||||
ssh macmini "git -C /Users/benjaminadmin/Projekte/breakpilot-compliance pull --no-rebase origin main"
|
||||
ssh macmini "/usr/local/bin/docker compose -f /Users/benjaminadmin/Projekte/breakpilot-compliance/docker-compose.yml build --no-cache <service> && /usr/local/bin/docker compose -f /Users/benjaminadmin/Projekte/breakpilot-compliance/docker-compose.yml up -d <service>"
|
||||
```
|
||||
|
||||
---
|
||||
@@ -228,6 +338,36 @@ ssh macmini "cd /Users/benjaminadmin/Projekte/breakpilot-compliance && git push
|
||||
- `lib/sdk/catalog-manager/` — catalog-registry.ts, types.ts
|
||||
- 17 DSGVO/AI-Act Kataloge (dsfa, vvt-baseline, vendor-compliance, etc.)
|
||||
|
||||
### Multi-Projekt-Architektur (seit 2026-03-09)
|
||||
|
||||
Jeder Tenant kann mehrere Compliance-Projekte anlegen. CompanyProfile ist **pro Projekt** (nicht tenant-weit).
|
||||
|
||||
**URL-Schema:** `/sdk?project={uuid}` — alle SDK-Seiten enthalten `?project=` Query-Param.
|
||||
`/sdk` ohne `?project=` zeigt die Projektliste (ProjectSelector).
|
||||
|
||||
**Datenbank:**
|
||||
- `compliance_projects` — Projekt-Metadaten (Name, Typ, Status, Version)
|
||||
- `sdk_states` — UNIQUE auf `(tenant_id, project_id)` statt nur `tenant_id`
|
||||
- Migration: `039_compliance_projects.sql`
|
||||
|
||||
**Backend API (FastAPI):**
|
||||
```
|
||||
GET /api/v1/projects → Alle Projekte des Tenants
|
||||
POST /api/v1/projects → Neues Projekt erstellen (mit copy_from_project_id)
|
||||
GET /api/v1/projects/{project_id} → Einzelnes Projekt laden
|
||||
PATCH /api/v1/projects/{project_id} → Projekt aktualisieren
|
||||
DELETE /api/v1/projects/{project_id} → Projekt archivieren (Soft Delete)
|
||||
```
|
||||
|
||||
**Frontend:**
|
||||
- `components/sdk/ProjectSelector/ProjectSelector.tsx` — Projektliste + Erstellen-Dialog
|
||||
- `lib/sdk/types.ts` — `ProjectInfo` Interface, `SDKState.projectId`
|
||||
- `lib/sdk/context.tsx` — `projectId` Prop, `createProject()`, `listProjects()`, `switchProject()`
|
||||
- `lib/sdk/sync.ts` — BroadcastChannel + localStorage pro Projekt
|
||||
- `lib/sdk/api-client.ts` — `projectId` in State-API + Projekt-CRUD-Methoden
|
||||
- `app/sdk/layout.tsx` — liest `?project=` aus searchParams
|
||||
- `app/api/sdk/v1/projects/` — Next.js Proxy zum Backend
|
||||
|
||||
### Backend-Compliance APIs
|
||||
```
|
||||
POST/GET /api/v1/compliance/risks
|
||||
@@ -237,18 +377,35 @@ POST/GET /api/v1/compliance/evidence
|
||||
POST/GET /api/v1/dsr/requests
|
||||
POST/GET /api/v1/gdpr/exports
|
||||
POST/GET /api/v1/consent/admin
|
||||
|
||||
# Stammdaten, Versionierung & Change-Requests
|
||||
GET/POST/DELETE /api/compliance/company-profile
|
||||
GET /api/compliance/company-profile/template-context
|
||||
GET /api/compliance/change-requests
|
||||
GET /api/compliance/change-requests/stats
|
||||
POST /api/compliance/change-requests/{id}/accept
|
||||
POST /api/compliance/change-requests/{id}/reject
|
||||
POST /api/compliance/change-requests/{id}/edit
|
||||
GET /api/compliance/generation/preview/{doc_type}
|
||||
POST /api/compliance/generation/apply/{doc_type}
|
||||
GET /api/compliance/{doc}/{id}/versions
|
||||
```
|
||||
|
||||
### Multi-Tenancy
|
||||
- Shared Dependency: `compliance/api/tenant_utils.py` (`get_tenant_id()`)
|
||||
- UUID-Format, kein `"default"` mehr
|
||||
- Header `X-Tenant-ID` > Query `tenant_id` > ENV-Fallback
|
||||
|
||||
---
|
||||
|
||||
## Wichtige Dateien (Referenz)
|
||||
|
||||
| Datei | Beschreibung |
|
||||
|-------|--------------|
|
||||
| `admin-compliance/app/(sdk)/` | Alle 37 SDK-Routes |
|
||||
| `admin-compliance/components/sdk/SDKSidebar.tsx` | SDK Navigation |
|
||||
| `admin-compliance/app/(sdk)/` | Alle 37+ SDK-Routes |
|
||||
| `admin-compliance/components/sdk/Sidebar/SDKSidebar.tsx` | SDK Navigation |
|
||||
| `admin-compliance/components/sdk/CommandBar.tsx` | Command Palette |
|
||||
| `admin-compliance/lib/sdk/context.tsx` | SDK State (Provider) |
|
||||
| `backend-compliance/compliance/` | Haupt-Package (40 Dateien) |
|
||||
| `backend-compliance/compliance/` | Haupt-Package (50+ Dateien) |
|
||||
| `ai-compliance-sdk/` | KI-Compliance Analyse |
|
||||
| `developer-portal/` | API-Dokumentation |
|
||||
|
||||
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`.
|
||||
103
.claude/rules/loc-exceptions.txt
Normal file
103
.claude/rules/loc-exceptions.txt
Normal file
@@ -0,0 +1,103 @@
|
||||
# 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
|
||||
|
||||
# --- backend-compliance: Phase 1 code refactor backlog ---
|
||||
# These are the remaining oversized route/service/data/auth files that Phase 1
|
||||
# did not reach. Each entry is a tracked refactor debt item — the list must shrink.
|
||||
backend-compliance/compliance/services/decomposition_pass.py
|
||||
backend-compliance/compliance/api/schemas.py
|
||||
backend-compliance/compliance/api/canonical_control_routes.py
|
||||
backend-compliance/compliance/db/repository.py
|
||||
backend-compliance/compliance/db/models.py
|
||||
backend-compliance/compliance/api/evidence_check_routes.py
|
||||
backend-compliance/compliance/api/control_generator_routes.py
|
||||
backend-compliance/compliance/api/process_task_routes.py
|
||||
backend-compliance/compliance/api/evidence_routes.py
|
||||
backend-compliance/compliance/api/crosswalk_routes.py
|
||||
backend-compliance/compliance/api/dashboard_routes.py
|
||||
backend-compliance/compliance/api/dsfa_routes.py
|
||||
backend-compliance/compliance/api/routes.py
|
||||
backend-compliance/compliance/api/tom_mapping_routes.py
|
||||
backend-compliance/compliance/services/control_dedup.py
|
||||
backend-compliance/compliance/services/framework_decomposition.py
|
||||
backend-compliance/compliance/services/pipeline_adapter.py
|
||||
backend-compliance/compliance/services/batch_dedup_runner.py
|
||||
backend-compliance/compliance/services/obligation_extractor.py
|
||||
backend-compliance/compliance/services/control_composer.py
|
||||
backend-compliance/compliance/services/pattern_matcher.py
|
||||
backend-compliance/compliance/data/iso27001_annex_a.py
|
||||
backend-compliance/compliance/data/service_modules.py
|
||||
backend-compliance/compliance/data/controls.py
|
||||
backend-compliance/services/pdf_service.py
|
||||
backend-compliance/services/file_processor.py
|
||||
backend-compliance/auth/keycloak_auth.py
|
||||
|
||||
# --- scripts: one-off ingestion, QA, and migration scripts ---
|
||||
# These are operational scripts, not production application code.
|
||||
# LOC rules don't apply in the same way to single-purpose scripts.
|
||||
scripts/ingest-legal-corpus.sh
|
||||
scripts/ingest-ce-corpus.sh
|
||||
scripts/ingest-dsfa-bundesland.sh
|
||||
scripts/edpb-crawler.py
|
||||
scripts/apply_templates_023.py
|
||||
scripts/qa/phase74_generate_gap_controls.py
|
||||
scripts/qa/pdf_qa_all.py
|
||||
scripts/qa/benchmark_llm_controls.py
|
||||
backend-compliance/scripts/seed_policy_templates.py
|
||||
|
||||
# --- docs-src: copies of backend source for documentation rendering ---
|
||||
# These are not production code; they are rendered into the static docs site.
|
||||
docs-src/control_generator.py
|
||||
docs-src/control_generator_routes.py
|
||||
|
||||
# --- consent-sdk: platform-native mobile SDKs (Swift / Dart) ---
|
||||
# Flutter and iOS SDKs follow platform conventions (verbose verbose) that make
|
||||
# splitting into multiple files awkward without sacrificing single-import ergonomics.
|
||||
consent-sdk/src/mobile/flutter/consent_sdk.dart
|
||||
consent-sdk/src/mobile/ios/ConsentManager.swift
|
||||
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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
14
.env.example
14
.env.example
@@ -4,7 +4,11 @@
|
||||
# Copy to .env and adjust values
|
||||
# NOTE: Core must be running! These vars reference Core services.
|
||||
|
||||
# Database (same as Core)
|
||||
# Compliance SDK Database (externe PostgreSQL — nie committen!)
|
||||
# Setzt DATABASE_URL fuer: backend-compliance, ai-compliance-sdk, document-crawler, admin-compliance
|
||||
COMPLIANCE_DATABASE_URL=postgresql://<user>:<pass>@<host>:<port>/<db>?sslmode=require
|
||||
|
||||
# Legacy Core Database (nur noch fuer Rollback; wird ignoriert wenn COMPLIANCE_DATABASE_URL gesetzt)
|
||||
POSTGRES_USER=breakpilot
|
||||
POSTGRES_PASSWORD=breakpilot123
|
||||
POSTGRES_DB=breakpilot_db
|
||||
@@ -44,3 +48,11 @@ SESSION_TTL_HOURS=24
|
||||
# SMTP (uses Core Mailpit)
|
||||
SMTP_HOST=bp-core-mailpit
|
||||
SMTP_PORT=1025
|
||||
|
||||
# Qdrant (externe Instanz — Hetzner/meghshakka)
|
||||
QDRANT_URL=https://qdrant-dev.breakpilot.ai
|
||||
QDRANT_API_KEY=<api-key>
|
||||
|
||||
# MinIO / Object Storage (Hetzner Object Storage)
|
||||
# MINIO_ENDPOINT, MINIO_ACCESS_KEY, MINIO_SECRET_KEY sind direkt in docker-compose hart kodiert
|
||||
# (compliance-tts-service: nbg1.your-objectstorage.com)
|
||||
|
||||
61
.env.orca.example
Normal file
61
.env.orca.example
Normal file
@@ -0,0 +1,61 @@
|
||||
# =========================================================
|
||||
# BreakPilot Compliance — Orca Environment Variables
|
||||
# =========================================================
|
||||
# Copy these into Orca's environment variable UI
|
||||
# for the breakpilot-compliance Docker Compose resource.
|
||||
# =========================================================
|
||||
|
||||
# --- External PostgreSQL (Orca-managed, same as Core) ---
|
||||
COMPLIANCE_DATABASE_URL=postgresql://breakpilot:CHANGE_ME@<orca-postgres-hostname>:5432/breakpilot_db
|
||||
|
||||
# --- Security ---
|
||||
JWT_SECRET=CHANGE_ME_SAME_AS_CORE
|
||||
|
||||
# --- External S3 Storage (same as Core) ---
|
||||
S3_ENDPOINT=<s3-endpoint-host:port>
|
||||
S3_ACCESS_KEY=CHANGE_ME_SAME_AS_CORE
|
||||
S3_SECRET_KEY=CHANGE_ME_SAME_AS_CORE
|
||||
S3_SECURE=true
|
||||
|
||||
# --- External Qdrant ---
|
||||
QDRANT_URL=https://<qdrant-hostname>
|
||||
QDRANT_API_KEY=CHANGE_ME_QDRANT_API_KEY
|
||||
|
||||
# --- Session ---
|
||||
SESSION_TTL_HOURS=24
|
||||
|
||||
# --- SMTP (Real mail server) ---
|
||||
SMTP_HOST=smtp.example.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USERNAME=compliance@breakpilot.ai
|
||||
SMTP_PASSWORD=CHANGE_ME_SMTP_PASSWORD
|
||||
SMTP_FROM_NAME=BreakPilot Compliance
|
||||
SMTP_FROM_ADDR=compliance@breakpilot.ai
|
||||
|
||||
# --- LLM Configuration ---
|
||||
COMPLIANCE_LLM_PROVIDER=anthropic
|
||||
SELF_HOSTED_LLM_URL=
|
||||
SELF_HOSTED_LLM_MODEL=
|
||||
COMPLIANCE_LLM_MAX_TOKENS=4096
|
||||
COMPLIANCE_LLM_TEMPERATURE=0.3
|
||||
COMPLIANCE_LLM_TIMEOUT=120
|
||||
ANTHROPIC_API_KEY=CHANGE_ME_ANTHROPIC_KEY
|
||||
ANTHROPIC_DEFAULT_MODEL=claude-sonnet-4-5-20250929
|
||||
|
||||
# --- Ollama (optional) ---
|
||||
OLLAMA_URL=
|
||||
OLLAMA_DEFAULT_MODEL=
|
||||
COMPLIANCE_LLM_MODEL=
|
||||
|
||||
# --- LLM Fallback ---
|
||||
LLM_FALLBACK_PROVIDER=
|
||||
|
||||
# --- PII & Audit ---
|
||||
PII_REDACTION_ENABLED=true
|
||||
PII_REDACTION_LEVEL=standard
|
||||
AUDIT_RETENTION_DAYS=365
|
||||
AUDIT_LOG_PROMPTS=true
|
||||
|
||||
# --- Frontend URLs (build args) ---
|
||||
NEXT_PUBLIC_API_URL=https://api-compliance.breakpilot.ai
|
||||
NEXT_PUBLIC_SDK_URL=https://sdk.breakpilot.ai
|
||||
221
.gitea/workflows/build-push-deploy.yml
Normal file
221
.gitea/workflows/build-push-deploy.yml
Normal file
@@ -0,0 +1,221 @@
|
||||
# Build + push compliance service images to registry.meghsakha.com
|
||||
# and trigger orca redeploy on every push to main that touches a service.
|
||||
#
|
||||
# Requires Gitea Actions secrets:
|
||||
# REGISTRY_USERNAME / REGISTRY_PASSWORD — registry.meghsakha.com credentials
|
||||
# ORCA_WEBHOOK_SECRET — must match webhooks.json on orca master
|
||||
|
||||
name: Build + Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'admin-compliance/**'
|
||||
- 'backend-compliance/**'
|
||||
- 'ai-compliance-sdk/**'
|
||||
- 'developer-portal/**'
|
||||
- 'compliance-tts-service/**'
|
||||
- 'document-crawler/**'
|
||||
- 'dsms-gateway/**'
|
||||
- 'dsms-node/**'
|
||||
|
||||
jobs:
|
||||
# ── per-service builds run in parallel ────────────────────────────────────
|
||||
|
||||
build-admin-compliance:
|
||||
runs-on: docker
|
||||
container: docker:27-cli
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
apk add --no-cache git
|
||||
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||
- name: Login
|
||||
env:
|
||||
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
||||
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
run: echo "$REGISTRY_PASSWORD" | docker login registry.meghsakha.com -u "$REGISTRY_USERNAME" --password-stdin
|
||||
- name: Build + push
|
||||
run: |
|
||||
SHORT_SHA=$(git rev-parse --short HEAD)
|
||||
docker build \
|
||||
-t registry.meghsakha.com/breakpilot/compliance-admin:latest \
|
||||
-t registry.meghsakha.com/breakpilot/compliance-admin:${SHORT_SHA} \
|
||||
admin-compliance/
|
||||
docker push registry.meghsakha.com/breakpilot/compliance-admin:latest
|
||||
docker push registry.meghsakha.com/breakpilot/compliance-admin:${SHORT_SHA}
|
||||
|
||||
build-backend-compliance:
|
||||
runs-on: docker
|
||||
container: docker:27-cli
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
apk add --no-cache git
|
||||
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||
- name: Login
|
||||
env:
|
||||
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
||||
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
run: echo "$REGISTRY_PASSWORD" | docker login registry.meghsakha.com -u "$REGISTRY_USERNAME" --password-stdin
|
||||
- name: Build + push
|
||||
run: |
|
||||
SHORT_SHA=$(git rev-parse --short HEAD)
|
||||
docker build \
|
||||
-t registry.meghsakha.com/breakpilot/compliance-backend:latest \
|
||||
-t registry.meghsakha.com/breakpilot/compliance-backend:${SHORT_SHA} \
|
||||
backend-compliance/
|
||||
docker push registry.meghsakha.com/breakpilot/compliance-backend:latest
|
||||
docker push registry.meghsakha.com/breakpilot/compliance-backend:${SHORT_SHA}
|
||||
|
||||
build-ai-sdk:
|
||||
runs-on: docker
|
||||
container: docker:27-cli
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
apk add --no-cache git
|
||||
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||
- name: Login
|
||||
env:
|
||||
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
||||
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
run: echo "$REGISTRY_PASSWORD" | docker login registry.meghsakha.com -u "$REGISTRY_USERNAME" --password-stdin
|
||||
- name: Build + push
|
||||
run: |
|
||||
SHORT_SHA=$(git rev-parse --short HEAD)
|
||||
docker build \
|
||||
-t registry.meghsakha.com/breakpilot/compliance-sdk:latest \
|
||||
-t registry.meghsakha.com/breakpilot/compliance-sdk:${SHORT_SHA} \
|
||||
ai-compliance-sdk/
|
||||
docker push registry.meghsakha.com/breakpilot/compliance-sdk:latest
|
||||
docker push registry.meghsakha.com/breakpilot/compliance-sdk:${SHORT_SHA}
|
||||
|
||||
build-developer-portal:
|
||||
runs-on: docker
|
||||
container: docker:27-cli
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
apk add --no-cache git
|
||||
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||
- name: Login
|
||||
env:
|
||||
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
||||
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
run: echo "$REGISTRY_PASSWORD" | docker login registry.meghsakha.com -u "$REGISTRY_USERNAME" --password-stdin
|
||||
- name: Build + push
|
||||
run: |
|
||||
SHORT_SHA=$(git rev-parse --short HEAD)
|
||||
docker build \
|
||||
-t registry.meghsakha.com/breakpilot/compliance-portal:latest \
|
||||
-t registry.meghsakha.com/breakpilot/compliance-portal:${SHORT_SHA} \
|
||||
developer-portal/
|
||||
docker push registry.meghsakha.com/breakpilot/compliance-portal:latest
|
||||
docker push registry.meghsakha.com/breakpilot/compliance-portal:${SHORT_SHA}
|
||||
|
||||
build-tts:
|
||||
runs-on: docker
|
||||
container: docker:27-cli
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
apk add --no-cache git
|
||||
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||
- name: Login
|
||||
env:
|
||||
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
||||
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
run: echo "$REGISTRY_PASSWORD" | docker login registry.meghsakha.com -u "$REGISTRY_USERNAME" --password-stdin
|
||||
- name: Build + push
|
||||
run: |
|
||||
SHORT_SHA=$(git rev-parse --short HEAD)
|
||||
docker build \
|
||||
-t registry.meghsakha.com/breakpilot/compliance-tts:latest \
|
||||
-t registry.meghsakha.com/breakpilot/compliance-tts:${SHORT_SHA} \
|
||||
compliance-tts-service/
|
||||
docker push registry.meghsakha.com/breakpilot/compliance-tts:latest
|
||||
docker push registry.meghsakha.com/breakpilot/compliance-tts:${SHORT_SHA}
|
||||
|
||||
build-document-crawler:
|
||||
runs-on: docker
|
||||
container: docker:27-cli
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
apk add --no-cache git
|
||||
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||
- name: Login
|
||||
env:
|
||||
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
||||
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
run: echo "$REGISTRY_PASSWORD" | docker login registry.meghsakha.com -u "$REGISTRY_USERNAME" --password-stdin
|
||||
- name: Build + push
|
||||
run: |
|
||||
SHORT_SHA=$(git rev-parse --short HEAD)
|
||||
docker build \
|
||||
-t registry.meghsakha.com/breakpilot/compliance-crawler:latest \
|
||||
-t registry.meghsakha.com/breakpilot/compliance-crawler:${SHORT_SHA} \
|
||||
document-crawler/
|
||||
docker push registry.meghsakha.com/breakpilot/compliance-crawler:latest
|
||||
docker push registry.meghsakha.com/breakpilot/compliance-crawler:${SHORT_SHA}
|
||||
|
||||
build-dsms-gateway:
|
||||
runs-on: docker
|
||||
container: docker:27-cli
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
apk add --no-cache git
|
||||
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||
- name: Login
|
||||
env:
|
||||
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
||||
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
run: echo "$REGISTRY_PASSWORD" | docker login registry.meghsakha.com -u "$REGISTRY_USERNAME" --password-stdin
|
||||
- name: Build + push
|
||||
run: |
|
||||
SHORT_SHA=$(git rev-parse --short HEAD)
|
||||
docker build \
|
||||
-t registry.meghsakha.com/breakpilot/compliance-dsms-gateway:latest \
|
||||
-t registry.meghsakha.com/breakpilot/compliance-dsms-gateway:${SHORT_SHA} \
|
||||
dsms-gateway/
|
||||
docker push registry.meghsakha.com/breakpilot/compliance-dsms-gateway:latest
|
||||
docker push registry.meghsakha.com/breakpilot/compliance-dsms-gateway:${SHORT_SHA}
|
||||
|
||||
# ── orca redeploy (only after all builds succeed) ─────────────────────────
|
||||
|
||||
trigger-orca:
|
||||
runs-on: docker
|
||||
container: docker:27-cli
|
||||
needs:
|
||||
- build-admin-compliance
|
||||
- build-backend-compliance
|
||||
- build-ai-sdk
|
||||
- build-developer-portal
|
||||
- build-tts
|
||||
- build-document-crawler
|
||||
- build-dsms-gateway
|
||||
steps:
|
||||
- name: Checkout (for SHA)
|
||||
run: |
|
||||
apk add --no-cache git curl openssl
|
||||
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||
- name: Trigger orca redeploy
|
||||
env:
|
||||
ORCA_WEBHOOK_SECRET: ${{ secrets.ORCA_WEBHOOK_SECRET }}
|
||||
ORCA_WEBHOOK_URL: http://46.225.100.82:6880/api/v1/webhooks/github
|
||||
run: |
|
||||
SHA=$(git rev-parse HEAD)
|
||||
PAYLOAD="{\"ref\":\"refs/heads/main\",\"repository\":{\"full_name\":\"${GITHUB_REPOSITORY}\"},\"head_commit\":{\"id\":\"$SHA\",\"message\":\"ci: compliance images built\"}}"
|
||||
SIG=$(printf '%s' "$PAYLOAD" | openssl dgst -sha256 -hmac "$ORCA_WEBHOOK_SECRET" -r | awk '{print $1}')
|
||||
curl -sSf -k \
|
||||
-X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-GitHub-Event: push" \
|
||||
-H "X-Hub-Signature-256: sha256=$SIG" \
|
||||
-d "$PAYLOAD" \
|
||||
"$ORCA_WEBHOOK_URL" \
|
||||
|| { echo "Orca redeploy failed"; exit 1; }
|
||||
echo "Orca redeploy triggered for compliance services"
|
||||
@@ -1,24 +1,92 @@
|
||||
# Gitea Actions CI Pipeline
|
||||
# BreakPilot Compliance
|
||||
# BreakPilot Compliance — CI Pipeline
|
||||
#
|
||||
# Services:
|
||||
# Go: ai-compliance-sdk
|
||||
# Python: backend-compliance, document-crawler, dsms-gateway
|
||||
# Node.js: admin-compliance, developer-portal
|
||||
# Feature branch workflow:
|
||||
# feat/* | feature/* | fix/* | hotfix/* | chore/* | refactor/* | docs/* | test/* | ci/*
|
||||
# → open PR targeting main
|
||||
# → all jobs run as PR gates
|
||||
# → squash merge to main
|
||||
# → subset of jobs re-run on main to catch merge surprises
|
||||
#
|
||||
# Deploy is handled by build-push-deploy.yml on push to main.
|
||||
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
# ========================================
|
||||
# Lint (nur bei PRs)
|
||||
# ========================================
|
||||
|
||||
# ── Branch naming convention (PR only) ──────────────────────────────────
|
||||
branch-name:
|
||||
runs-on: docker
|
||||
container: alpine:3.20
|
||||
if: github.event_name == 'pull_request'
|
||||
steps:
|
||||
- name: Validate branch name
|
||||
run: |
|
||||
BRANCH="${GITHUB_HEAD_REF}"
|
||||
if ! echo "$BRANCH" | grep -qE '^(feat|feature|fix|hotfix|chore|refactor|docs|test|ci)/.+'; then
|
||||
echo "::error::Branch '$BRANCH' does not follow naming convention."
|
||||
echo "Required prefix: feat/ feature/ fix/ hotfix/ chore/ refactor/ docs/ test/ ci/"
|
||||
exit 1
|
||||
fi
|
||||
echo "Branch name OK: $BRANCH"
|
||||
|
||||
# ── Guardrail integrity (PR only) ────────────────────────────────────────
|
||||
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] in commits touching guardrails
|
||||
run: |
|
||||
changed=$(git diff --name-only base...HEAD)
|
||||
echo "$changed" | grep -qE '^(\.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 modified without [guardrail-change] in any commit message."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── LOC budget (always) ──────────────────────────────────────────────────
|
||||
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
|
||||
run: |
|
||||
chmod +x scripts/check-loc.sh
|
||||
scripts/check-loc.sh
|
||||
|
||||
# ── Secret scanning (PR only) ────────────────────────────────────────────
|
||||
secret-scan:
|
||||
runs-on: docker
|
||||
container: zricethezav/gitleaks:v8.21.2
|
||||
if: github.event_name == 'pull_request'
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
apk add --no-cache git
|
||||
git clone --depth 50 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||
- name: Scan for secrets
|
||||
run: |
|
||||
gitleaks detect --source . --no-git \
|
||||
--exit-code 1 \
|
||||
--redact \
|
||||
|| { echo "::error::Secrets detected — remove them before merging."; exit 1; }
|
||||
|
||||
# ── Go lint + build (PR only) ────────────────────────────────────────────
|
||||
go-lint:
|
||||
runs-on: docker
|
||||
if: github.event_name == 'pull_request'
|
||||
@@ -30,10 +98,16 @@ jobs:
|
||||
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||
- name: Lint ai-compliance-sdk
|
||||
run: |
|
||||
if [ -d "ai-compliance-sdk" ]; then
|
||||
cd ai-compliance-sdk && golangci-lint run --timeout 5m ./...
|
||||
fi
|
||||
[ -d "ai-compliance-sdk" ] || exit 0
|
||||
cd ai-compliance-sdk
|
||||
golangci-lint run --timeout 5m ./...
|
||||
- name: Build ai-compliance-sdk
|
||||
run: |
|
||||
[ -d "ai-compliance-sdk" ] || exit 0
|
||||
cd ai-compliance-sdk
|
||||
go build ./...
|
||||
|
||||
# ── Python lint + import check (PR only) ────────────────────────────────
|
||||
python-lint:
|
||||
runs-on: docker
|
||||
if: github.event_name == 'pull_request'
|
||||
@@ -43,16 +117,27 @@ 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 (ruff) + type-check (mypy)
|
||||
run: |
|
||||
pip install --quiet ruff
|
||||
for svc in backend-compliance document-crawler dsms-gateway; do
|
||||
if [ -d "$svc" ]; then
|
||||
echo "=== Linting $svc ==="
|
||||
ruff check "$svc/" --output-format=github || true
|
||||
fi
|
||||
pip install --quiet ruff mypy
|
||||
fail=0
|
||||
for svc in backend-compliance document-crawler dsms-gateway compliance-tts-service; do
|
||||
[ -d "$svc" ] || continue
|
||||
echo "=== ruff: $svc ===" && ruff check "$svc/" --output-format=github || fail=1
|
||||
done
|
||||
if [ -f "backend-compliance/mypy.ini" ]; then
|
||||
cd backend-compliance && mypy compliance/ || fail=1
|
||||
fi
|
||||
exit $fail
|
||||
- name: Import sanity check (catches NameError at collection time)
|
||||
run: |
|
||||
cd backend-compliance
|
||||
pip install --quiet --no-cache-dir -r requirements.txt 2>/dev/null || true
|
||||
export PYTHONPATH="$(pwd):${PYTHONPATH:-}"
|
||||
python -c "import compliance; print('Import OK')" \
|
||||
|| { echo "::error::compliance package fails to import — missing import or syntax error."; exit 1; }
|
||||
|
||||
# ── Node.js lint + type-check (PR only) ─────────────────────────────────
|
||||
nodejs-lint:
|
||||
runs-on: docker
|
||||
if: github.event_name == 'pull_request'
|
||||
@@ -62,23 +147,105 @@ 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
|
||||
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 ..
|
||||
fi
|
||||
[ -d "$svc" ] || continue
|
||||
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 ===" && (cd "$svc" && npx tsc --noEmit) || fail=1
|
||||
done
|
||||
exit $fail
|
||||
|
||||
# ========================================
|
||||
# Unit Tests
|
||||
# ========================================
|
||||
# ── Node.js build — next build (PR + push to main) ───────────────────────
|
||||
nodejs-build:
|
||||
runs-on: docker
|
||||
container: node:20-alpine
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
apk add --no-cache git
|
||||
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||
- name: Build Next.js services
|
||||
run: |
|
||||
fail=0
|
||||
for svc in admin-compliance developer-portal; do
|
||||
[ -d "$svc" ] || continue
|
||||
echo "=== $svc: install ==="
|
||||
(cd "$svc" && npm ci --silent 2>/dev/null || npm install --silent)
|
||||
echo "=== $svc: next build ==="
|
||||
(cd "$svc" && \
|
||||
NEXT_PUBLIC_API_URL=https://api-dev.breakpilot.ai \
|
||||
NEXT_PUBLIC_SDK_URL=https://sdk-dev.breakpilot.ai \
|
||||
npm run build) || fail=1
|
||||
done
|
||||
exit $fail
|
||||
|
||||
test-go-ai-compliance:
|
||||
# ── Dependency audit (PR only) ───────────────────────────────────────────
|
||||
dep-audit:
|
||||
runs-on: docker
|
||||
if: github.event_name == 'pull_request'
|
||||
container: python:3.12-slim
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
apt-get update -qq && apt-get install -y -qq git curl > /dev/null 2>&1
|
||||
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||
- name: Install Node.js + Go
|
||||
run: |
|
||||
curl -fsSL https://deb.nodesource.com/setup_20.x | bash - > /dev/null 2>&1
|
||||
apt-get install -y nodejs golang-go > /dev/null 2>&1
|
||||
- name: Python — pip-audit
|
||||
run: |
|
||||
pip install --quiet pip-audit
|
||||
fail=0
|
||||
for svc in backend-compliance document-crawler dsms-gateway compliance-tts-service; do
|
||||
[ -f "$svc/requirements.txt" ] || continue
|
||||
echo "=== pip-audit: $svc ==="
|
||||
pip-audit -r "$svc/requirements.txt" --skip-editable -f columns || fail=1
|
||||
done
|
||||
exit $fail
|
||||
- name: Node.js — npm audit
|
||||
run: |
|
||||
fail=0
|
||||
for svc in admin-compliance developer-portal; do
|
||||
[ -d "$svc" ] || continue
|
||||
echo "=== npm audit: $svc ==="
|
||||
(cd "$svc" && npm audit --audit-level=moderate --json 2>/dev/null | \
|
||||
node -e "const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')); \
|
||||
const hi=Object.values(d.vulnerabilities||{}).filter(v=>['high','critical'].includes(v.severity)).length; \
|
||||
if(hi>0){console.error('HIGH/CRITICAL: '+hi);process.exit(1)}") || fail=1
|
||||
done
|
||||
exit $fail
|
||||
- name: Go — govulncheck
|
||||
run: |
|
||||
[ -d "ai-compliance-sdk" ] || exit 0
|
||||
go install golang.org/x/vuln/cmd/govulncheck@latest 2>/dev/null
|
||||
cd ai-compliance-sdk && govulncheck ./... || true
|
||||
# Non-blocking until Go module versions are pinned
|
||||
|
||||
# ── SBOM + vulnerability scan (PR only) ─────────────────────────────────
|
||||
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
|
||||
|
||||
# ── Tests (PR + push to main) ─────────────────────────────────────────────
|
||||
test-go:
|
||||
runs-on: docker
|
||||
container: golang:1.24-alpine
|
||||
env:
|
||||
@@ -90,16 +257,12 @@ jobs:
|
||||
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||
- name: Test ai-compliance-sdk
|
||||
run: |
|
||||
if [ ! -d "ai-compliance-sdk" ]; then
|
||||
echo "WARNUNG: ai-compliance-sdk nicht gefunden"
|
||||
exit 0
|
||||
fi
|
||||
[ -d "ai-compliance-sdk" ] || exit 0
|
||||
cd ai-compliance-sdk
|
||||
go test -v -coverprofile=coverage.out ./... 2>&1
|
||||
COVERAGE=$(go tool cover -func=coverage.out 2>/dev/null | tail -1 | awk '{print $3}' || echo "0%")
|
||||
echo "Coverage: $COVERAGE"
|
||||
go test -v -coverprofile=coverage.out ./...
|
||||
go tool cover -func=coverage.out | tail -1
|
||||
|
||||
test-python-backend-compliance:
|
||||
test-python-backend:
|
||||
runs-on: docker
|
||||
container: python:3.12-slim
|
||||
env:
|
||||
@@ -111,14 +274,11 @@ jobs:
|
||||
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||
- name: Test backend-compliance
|
||||
run: |
|
||||
if [ ! -d "backend-compliance" ]; then
|
||||
echo "WARNUNG: backend-compliance nicht gefunden"
|
||||
exit 0
|
||||
fi
|
||||
[ -d "backend-compliance" ] || exit 0
|
||||
cd backend-compliance
|
||||
export PYTHONPATH="$(pwd):${PYTHONPATH:-}"
|
||||
pip install --quiet --no-cache-dir -r requirements.txt 2>/dev/null || true
|
||||
pip install --quiet --no-cache-dir fastapi uvicorn pytest pytest-asyncio
|
||||
pip install --quiet --no-cache-dir pytest pytest-asyncio
|
||||
python -m pytest compliance/tests/ -v --tb=short
|
||||
|
||||
test-python-document-crawler:
|
||||
@@ -133,10 +293,7 @@ jobs:
|
||||
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||
- name: Test document-crawler
|
||||
run: |
|
||||
if [ ! -d "document-crawler" ]; then
|
||||
echo "WARNUNG: document-crawler nicht gefunden"
|
||||
exit 0
|
||||
fi
|
||||
[ -d "document-crawler" ] || exit 0
|
||||
cd document-crawler
|
||||
export PYTHONPATH="$(pwd):${PYTHONPATH:-}"
|
||||
pip install --quiet --no-cache-dir -r requirements.txt 2>/dev/null || true
|
||||
@@ -155,12 +312,21 @@ jobs:
|
||||
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||
- name: Test dsms-gateway
|
||||
run: |
|
||||
if [ ! -d "dsms-gateway" ]; then
|
||||
echo "WARNUNG: dsms-gateway nicht gefunden"
|
||||
exit 0
|
||||
fi
|
||||
[ -d "dsms-gateway" ] || exit 0
|
||||
cd dsms-gateway
|
||||
export PYTHONPATH="$(pwd):${PYTHONPATH:-}"
|
||||
pip install --quiet --no-cache-dir -r requirements.txt 2>/dev/null || true
|
||||
pip install --quiet --no-cache-dir pytest pytest-asyncio
|
||||
python -m pytest test_main.py -v --tb=short
|
||||
|
||||
# ── OpenAPI contract validation (always) ─────────────────────────────────
|
||||
validate-canonical-controls:
|
||||
runs-on: docker
|
||||
container: python:3.12-slim
|
||||
steps:
|
||||
- name: Checkout
|
||||
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: Validate controls
|
||||
run: python scripts/validate-controls.py
|
||||
|
||||
115
.gitea/workflows/rag-ingest.yaml
Normal file
115
.gitea/workflows/rag-ingest.yaml
Normal file
@@ -0,0 +1,115 @@
|
||||
# Gitea Actions — RAG Legal Corpus Ingestion
|
||||
#
|
||||
# Manuell triggerbarer Workflow zur Ingestion von Rechtstexten in Qdrant.
|
||||
# Trigger: Gitea UI → Actions → "RAG Ingestion" → Run
|
||||
#
|
||||
# Phasen: gesetze, eu, templates, datenschutz, verbraucherschutz, verify, version, all
|
||||
#
|
||||
# Voraussetzung: RAG-Service und Qdrant muessen auf Orca laufen.
|
||||
# Die BreakPilot-Services muessen deployed sein (ci.yaml deploy-orca).
|
||||
|
||||
name: RAG Ingestion
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
phase:
|
||||
description: 'Ingestion Phase (gesetze, eu, templates, datenschutz, verbraucherschutz, dach, security, verify, version, all)'
|
||||
required: true
|
||||
default: 'verbraucherschutz'
|
||||
|
||||
jobs:
|
||||
ingest:
|
||||
runs-on: docker
|
||||
container: docker:27-cli
|
||||
steps:
|
||||
- name: Setup
|
||||
run: |
|
||||
apk add --no-cache git curl bash > /dev/null 2>&1
|
||||
|
||||
- name: Checkout
|
||||
run: |
|
||||
git clone --depth 1 --branch main ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||
|
||||
- name: Run Ingestion
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PHASE="${{ github.event.inputs.phase }}"
|
||||
|
||||
echo "=== RAG Ingestion: Phase ${PHASE} ==="
|
||||
echo ""
|
||||
|
||||
# Pruefen ob Services laufen
|
||||
echo "--- BreakPilot Container ---"
|
||||
docker ps --filter name=bp- --format "{{.Names}}: {{.Status}}" 2>/dev/null || true
|
||||
echo ""
|
||||
|
||||
# Netzwerk finden in dem die bp-Services laufen
|
||||
BP_NETWORK=$(docker inspect bp-core-rag-service --format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}}{{end}}' 2>/dev/null || echo "")
|
||||
if [ -z "$BP_NETWORK" ]; then
|
||||
BP_NETWORK=$(docker inspect bp-compliance-backend --format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}}{{end}}' 2>/dev/null || echo "")
|
||||
fi
|
||||
|
||||
if [ -z "$BP_NETWORK" ]; then
|
||||
echo "FEHLER: Keine BreakPilot-Container gefunden."
|
||||
echo "Bitte zuerst deployen (CI/CD Pipeline oder manuell)."
|
||||
echo ""
|
||||
echo "Verfuegbare Container:"
|
||||
docker ps --format " {{.Names}}" 2>/dev/null || true
|
||||
echo ""
|
||||
echo "Verfuegbare Netzwerke:"
|
||||
docker network ls --format " {{.Name}}" 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "BreakPilot Netzwerk: $BP_NETWORK"
|
||||
echo ""
|
||||
|
||||
# Ingestion-Container erstellen (noch nicht starten),
|
||||
# dann Scripts aus dem Checkout per docker cp hineinkopieren.
|
||||
# So verwenden wir IMMER die neueste Version der Scripts,
|
||||
# unabhaengig vom Deploy-Dir auf dem Host.
|
||||
CONTAINER_ID=$(docker create \
|
||||
--network "$BP_NETWORK" \
|
||||
-e "WORK_DIR=/tmp/rag-ingestion" \
|
||||
-e "RAG_URL=http://bp-core-rag-service:8097/api/v1/documents/upload" \
|
||||
-e "QDRANT_URL=https://qdrant-dev.breakpilot.ai" \
|
||||
-e "QDRANT_API_KEY=z9cKbT74vl1aKPD1QGIlKWfET47VH93u" \
|
||||
-e "SDK_URL=http://bp-compliance-ai-sdk:8090" \
|
||||
alpine:3.19 \
|
||||
sh -c "
|
||||
apk add --no-cache curl bash coreutils git python3 unzip > /dev/null 2>&1
|
||||
mkdir -p /tmp/rag-ingestion/{pdfs,repos,texts}
|
||||
mkdir -p /workspace/scripts
|
||||
cp -r /workspace_scripts/* /workspace/scripts/ 2>/dev/null || true
|
||||
cd /workspace
|
||||
if [ '${PHASE}' = 'all' ]; then
|
||||
bash scripts/ingest-legal-corpus.sh
|
||||
elif [ '${PHASE}' = 'download' ]; then
|
||||
bash scripts/ingest-legal-corpus.sh --only download
|
||||
else
|
||||
echo '=== Running download phase first ==='
|
||||
bash scripts/ingest-legal-corpus.sh --only download
|
||||
echo ''
|
||||
echo '=== Running phase: ${PHASE} ==='
|
||||
bash scripts/ingest-legal-corpus.sh --only '${PHASE}'
|
||||
fi
|
||||
")
|
||||
|
||||
echo "Container: $CONTAINER_ID"
|
||||
|
||||
# Workspace-Dir im Container anlegen und Scripts hineinkopieren
|
||||
docker cp scripts "${CONTAINER_ID}:/workspace_scripts"
|
||||
echo "Scripts kopiert (aus Git-Checkout)"
|
||||
|
||||
# Container starten und Output streamen
|
||||
docker start -a "${CONTAINER_ID}" || EXITCODE=$?
|
||||
|
||||
# Container aufraeumen
|
||||
docker rm -f "${CONTAINER_ID}" 2>/dev/null || true
|
||||
|
||||
echo ""
|
||||
echo "=== Ingestion abgeschlossen ==="
|
||||
|
||||
# Exit mit dem Original-Exitcode
|
||||
exit ${EXITCODE:-0}
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -11,12 +11,19 @@ secrets/
|
||||
# Node
|
||||
node_modules/
|
||||
.next/
|
||||
dist/
|
||||
.turbo/
|
||||
pnpm-lock.yaml
|
||||
.pnpm-store/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
venv/
|
||||
.venv/
|
||||
.coverage
|
||||
coverage.out
|
||||
test_*.db
|
||||
|
||||
# Docker
|
||||
backups/*.backup
|
||||
@@ -40,3 +47,4 @@ backups/*.backup
|
||||
*.mp3
|
||||
*.wav
|
||||
ai-compliance-sdk/server
|
||||
*.bak
|
||||
|
||||
176
AGENTS.go.md
Normal file
176
AGENTS.go.md
Normal file
@@ -0,0 +1,176 @@
|
||||
# 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
|
||||
|
||||
Run lint before pushing:
|
||||
|
||||
```bash
|
||||
golangci-lint run --timeout 5m ./...
|
||||
```
|
||||
|
||||
The `.golangci.yml` at the service root (`ai-compliance-sdk/.golangci.yml`) enables: `errcheck, govet, staticcheck, gosec, gocyclo (≤20), gocritic, revive, goimports, unused, ineffassign`. Fix lint violations in new code; legacy violations are tracked but not required to fix immediately.
|
||||
|
||||
- `gofumpt` formatting.
|
||||
- `go vet ./...` clean.
|
||||
- `go mod tidy` clean — no unused deps.
|
||||
|
||||
## File splitting pattern
|
||||
|
||||
When a Go file exceeds the 500-line hard cap, split it in place — no new packages needed:
|
||||
|
||||
- All split files stay in **the same package directory** with the **same `package <name>` declaration**.
|
||||
- No import changes are needed anywhere because Go packages are directory-scoped.
|
||||
- Naming: `store_projects.go`, `store_components.go` (noun + underscore + sub-resource).
|
||||
- For handlers: `iace_handler_projects.go`, `iace_handler_hazards.go`, etc.
|
||||
- Before splitting, add a characterization test that pins current behaviour.
|
||||
|
||||
## Error handling
|
||||
|
||||
Domain errors are defined in `internal/domain/<aggregate>/errors.go` as sentinel vars or typed errors. The mapping from domain error to HTTP status lives exclusively in `internal/platform/httperr/httperr.go` via `errors.Is` / `errors.As`. Handlers call `httperr.Write(c, err)` — **never** directly call `c.JSON` with a status code derived from business logic.
|
||||
|
||||
## Context propagation
|
||||
|
||||
- Always pass `ctx context.Context` as the **first parameter** in every service and repository method.
|
||||
- Never store a context in a struct field — pass it per call.
|
||||
- Cancellation must be respected: check `ctx.Err()` in loops; propagate to all I/O calls.
|
||||
|
||||
## 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.
|
||||
|
||||
## Before every push — MANDATORY
|
||||
|
||||
Run all steps for `ai-compliance-sdk/` before pushing. CI runs the same checks and will fail if you skip this.
|
||||
|
||||
```bash
|
||||
cd ai-compliance-sdk
|
||||
|
||||
# 1. Vet + lint
|
||||
go vet ./...
|
||||
golangci-lint run --timeout 5m ./...
|
||||
|
||||
# 2. Tests
|
||||
go test ./...
|
||||
|
||||
# 3. Build
|
||||
go build ./...
|
||||
```
|
||||
|
||||
All steps must exit 0. Do not push if any step fails.
|
||||
|
||||
## 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`.
|
||||
- Use `interface{}` / `any` in new code without an explicit comment justifying it.
|
||||
- Call `log.Fatal` outside of `main.go`; panicking in request handling is also forbidden.
|
||||
- Shadow `err` with `:=` inside an `if`-block when the outer scope already declares `err` — use `=` or rename.
|
||||
- Create a file >500 lines.
|
||||
- Change a public route's contract without updating consumers.
|
||||
168
AGENTS.python.md
Normal file
168
AGENTS.python.md
Normal file
@@ -0,0 +1,168 @@
|
||||
# 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`.
|
||||
|
||||
## mypy configuration
|
||||
|
||||
`backend-compliance/mypy.ini` is the mypy config. Strict mode is on globally; per-module overrides exist only for legacy files that have not been cleaned up yet.
|
||||
|
||||
- New modules added to `compliance/services/` or `compliance/repositories/` **must** pass `mypy --strict`.
|
||||
- To type-check a new module: `cd backend-compliance && mypy compliance/your_new_module.py`
|
||||
- When you fully type a legacy file, **remove its loose-override block** from `mypy.ini` as part of the same PR.
|
||||
|
||||
## Dependency injection
|
||||
|
||||
Services and repositories are wired via FastAPI `Depends`. Never instantiate a service or repository directly inside a handler.
|
||||
|
||||
```python
|
||||
# dependencies.py
|
||||
def get_my_service(db: AsyncSession = Depends(get_db)) -> MyService:
|
||||
return MyService(MyRepository(db))
|
||||
|
||||
# router
|
||||
@router.get("/items", response_model=list[ItemRead])
|
||||
async def list_items(svc: MyService = Depends(get_my_service)) -> list[ItemRead]:
|
||||
return await svc.list()
|
||||
```
|
||||
|
||||
- Services take repositories in `__init__`; repositories take `Session` or `AsyncSession`.
|
||||
|
||||
## Structured logging
|
||||
|
||||
```python
|
||||
import structlog
|
||||
logger = structlog.get_logger()
|
||||
|
||||
# Always bind context before logging:
|
||||
logger.bind(tenant_id=str(tid), action="create_dsfa").info("dsfa created")
|
||||
```
|
||||
|
||||
- Audit-relevant actions must use the audit logger with a `legal_basis` field.
|
||||
- Never log secrets, PII, or full request bodies.
|
||||
|
||||
## Barrel re-export pattern
|
||||
|
||||
When an oversized file (e.g. `schemas.py`, `models.py`) is split into a sub-package, the original stays as a **thin re-exporter** so existing consumer imports keep working:
|
||||
|
||||
```python
|
||||
# compliance/schemas.py (barrel — DO NOT ADD NEW CODE HERE)
|
||||
from .schemas.ai import * # noqa: F401, F403
|
||||
from .schemas.consent import * # noqa: F401, F403
|
||||
```
|
||||
|
||||
- New code imports from the specific module (e.g. `from compliance.schemas.ai import AIRiskRead`), not the barrel.
|
||||
- `from module import *` is only permitted in barrel files.
|
||||
|
||||
## 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.
|
||||
|
||||
## Before every push — MANDATORY
|
||||
|
||||
Run all three steps for every Python service you touched before pushing. CI runs the same checks and will fail if you skip this.
|
||||
|
||||
```bash
|
||||
cd <service> # backend-compliance | document-crawler | dsms-gateway | compliance-tts-service
|
||||
|
||||
# 1. Lint
|
||||
ruff check .
|
||||
mypy compliance/ # only for backend-compliance
|
||||
|
||||
# 2. Tests
|
||||
pytest -x
|
||||
|
||||
# 3. Import sanity (catches NameError at collection time)
|
||||
python -c "import compliance" # or the service's main module
|
||||
```
|
||||
|
||||
All steps must exit 0. Do not push if any step fails.
|
||||
|
||||
## 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.
|
||||
- `from module import *` in new code — only in barrel re-exporters.
|
||||
- `raise HTTPException` inside the service layer — raise domain exceptions; map them in the router.
|
||||
- Use `model_validate` on untrusted external data without an explicit schema boundary.
|
||||
- Create a new file >500 lines. Period.
|
||||
145
AGENTS.typescript.md
Normal file
145
AGENTS.typescript.md
Normal file
@@ -0,0 +1,145 @@
|
||||
# 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 `safeParse` — never `parse` (throws and bypasses error handling).
|
||||
- Delegate to `lib/server/<domain>/`. No business logic in `route.ts`.
|
||||
- Always return `NextResponse.json(..., { status })`. Let the framework's error boundary handle unexpected errors — don't wrap the entire handler in `try/catch`.
|
||||
|
||||
```typescript
|
||||
// app/api/<domain>/route.ts (≤40 LOC)
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { mySchema } from '@/lib/schemas/<domain>';
|
||||
import { myService } from '@/lib/server/<domain>';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const body = mySchema.safeParse(await req.json());
|
||||
if (!body.success) return NextResponse.json({ error: body.error }, { status: 400 });
|
||||
const result = await myService.create(body.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.
|
||||
- Always use `import type { Foo }` for type-only imports.
|
||||
- Never use `as` type assertions except when bridging external data at a boundary (add a comment explaining why).
|
||||
- No `@ts-ignore`. `@ts-expect-error` only with a comment explaining the suppression.
|
||||
|
||||
## Barrel re-export pattern
|
||||
|
||||
`lib/sdk/types.ts` is a barrel — it re-exports from domain-specific files. **Do not add new types directly to it.**
|
||||
|
||||
```typescript
|
||||
// lib/sdk/types.ts (barrel — DO NOT ADD NEW TYPES HERE)
|
||||
export * from './types/enums';
|
||||
export * from './types/company-profile';
|
||||
// ... etc.
|
||||
|
||||
// New types go in lib/sdk/types/<domain>.ts
|
||||
```
|
||||
|
||||
- When splitting an oversized file, keep the original as a thin barrel so existing imports don't break.
|
||||
- New code imports directly from the specific module (e.g. `import type { CompanyProfile } from '@/lib/sdk/types/company-profile'`), not the barrel.
|
||||
|
||||
## Server vs Client components
|
||||
|
||||
Default is Server Component. Add `"use client"` only when required:
|
||||
|
||||
| Need | Pattern |
|
||||
|------|---------|
|
||||
| Data fetching only | Server Component (no directive) |
|
||||
| `useState` / `useEffect` | Client Component (`"use client"`) |
|
||||
| Browser API | Client Component |
|
||||
| Event handlers | Client Component |
|
||||
|
||||
- Pass only serializable props from Server → Client Components (no functions, no class instances).
|
||||
- Never add `"use client"` to a layout or page just because one child needs it — extract the client part into a `_components/` file.
|
||||
|
||||
## 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.
|
||||
|
||||
## Before every push — MANDATORY
|
||||
|
||||
Run all three steps for every affected service (`admin-compliance/`, `developer-portal/`) before pushing. CI runs the same checks and will fail if you skip this.
|
||||
|
||||
```bash
|
||||
cd admin-compliance # or developer-portal
|
||||
|
||||
# 1. Build — catches type errors and module resolution failures
|
||||
npm run build
|
||||
|
||||
# 2. Lint
|
||||
npx tsc --noEmit
|
||||
npx eslint . --max-warnings 0
|
||||
|
||||
# 3. Tests
|
||||
npm test
|
||||
```
|
||||
|
||||
All three must exit 0. Do not push if any step fails.
|
||||
|
||||
## 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 DOMPurify sanitization.
|
||||
- Call internal backend APIs directly from Client Components — use Server Components or API routes as a proxy.
|
||||
- Add `"use client"` to a layout or page just because one child needs it — extract the client part.
|
||||
- Spread `...props` onto a DOM element without filtering the props first (type error risk).
|
||||
- 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.
|
||||
202
CONTRIBUTING.md
Normal file
202
CONTRIBUTING.md
Normal file
@@ -0,0 +1,202 @@
|
||||
# Contributing to breakpilot-compliance
|
||||
|
||||
---
|
||||
|
||||
## 1. Getting Started
|
||||
|
||||
```bash
|
||||
git clone ssh://git@gitea.meghsakha.com:22222/Benjamin_Boenisch/breakpilot-compliance.git
|
||||
cd breakpilot-compliance
|
||||
```
|
||||
|
||||
**Branch conventions** (branch from `main`):
|
||||
|
||||
| Prefix | Use for |
|
||||
|--------|---------|
|
||||
| `feature/` | New functionality |
|
||||
| `fix/` | Bug fixes |
|
||||
| `chore/` | Tooling, deps, CI, docs |
|
||||
|
||||
Example: `git checkout -b feature/ai-sdk-risk-scoring`
|
||||
|
||||
---
|
||||
|
||||
## 2. Dev Environment
|
||||
|
||||
Each service runs independently. Start only what you need.
|
||||
|
||||
**Go — ai-compliance-sdk**
|
||||
```bash
|
||||
cd ai-compliance-sdk
|
||||
go run ./cmd/server
|
||||
```
|
||||
|
||||
**Python — backend-compliance**
|
||||
```bash
|
||||
cd backend-compliance
|
||||
pip install -r requirements.txt
|
||||
uvicorn main:app --reload
|
||||
```
|
||||
|
||||
**Python — dsms-gateway / document-crawler / compliance-tts-service**
|
||||
```bash
|
||||
cd <service>
|
||||
pip install -r requirements.txt
|
||||
uvicorn main:app --reload --port <port>
|
||||
```
|
||||
|
||||
**Node.js — admin-compliance**
|
||||
```bash
|
||||
cd admin-compliance
|
||||
npm install
|
||||
npm run dev # http://localhost:3007
|
||||
```
|
||||
|
||||
**Node.js — developer-portal**
|
||||
```bash
|
||||
cd developer-portal
|
||||
npm install
|
||||
npm run dev # http://localhost:3006
|
||||
```
|
||||
|
||||
**All services together (local Docker)**
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Config lives in `.env` (not committed). Copy `.env.example` and fill in `COMPLIANCE_DATABASE_URL`, `QDRANT_URL`, `QDRANT_API_KEY`, and Vault tokens.
|
||||
|
||||
---
|
||||
|
||||
## 3. Before Your First Commit
|
||||
|
||||
Run all of these locally. CI will run the same checks and fail if they don't pass.
|
||||
|
||||
**LOC budget (mandatory)**
|
||||
```bash
|
||||
bash scripts/check-loc.sh # must exit 0
|
||||
```
|
||||
|
||||
**Go lint**
|
||||
```bash
|
||||
cd ai-compliance-sdk
|
||||
golangci-lint run --timeout 5m ./...
|
||||
```
|
||||
|
||||
**Python lint**
|
||||
```bash
|
||||
cd backend-compliance
|
||||
ruff check .
|
||||
mypy compliance/ # only if mypy.ini exists
|
||||
```
|
||||
|
||||
**TypeScript type-check**
|
||||
```bash
|
||||
cd admin-compliance
|
||||
npx tsc --noEmit
|
||||
```
|
||||
|
||||
**Tests**
|
||||
```bash
|
||||
# Go
|
||||
cd ai-compliance-sdk && go test ./...
|
||||
|
||||
# Python backend
|
||||
cd backend-compliance && pytest
|
||||
|
||||
# DSMS gateway
|
||||
cd dsms-gateway && pytest test_main.py
|
||||
```
|
||||
|
||||
If any step fails, fix it before committing. The git pre-commit hook re-runs `check-loc.sh` automatically.
|
||||
|
||||
---
|
||||
|
||||
## 4. Commit Message Rules
|
||||
|
||||
Use [Conventional Commits](https://www.conventionalcommits.org/) style:
|
||||
|
||||
```
|
||||
<type>(<scope>): <short summary>
|
||||
|
||||
[optional body]
|
||||
[optional footer]
|
||||
```
|
||||
|
||||
Types: `feat`, `fix`, `chore`, `refactor`, `test`, `docs`, `ci`.
|
||||
|
||||
### `[guardrail-change]` marker — REQUIRED
|
||||
|
||||
Add `[guardrail-change]` anywhere in the commit message body (or footer) when your changeset touches **any** of these files:
|
||||
|
||||
| File / path | Reason protected |
|
||||
|-------------|-----------------|
|
||||
| `.claude/settings.json` | PreToolUse/PostToolUse hooks |
|
||||
| `scripts/check-loc.sh` | LOC enforcement script |
|
||||
| `scripts/githooks/pre-commit` | Git hook |
|
||||
| `.claude/rules/loc-exceptions.txt` | Exception registry |
|
||||
| `AGENTS.*.md` (any) | Per-language architecture rules |
|
||||
|
||||
The `guardrail-integrity` CI job checks for this marker and **fails the build** if it is missing.
|
||||
|
||||
**Valid guardrail commit example:**
|
||||
```
|
||||
chore(guardrail): add exception for generated protobuf file
|
||||
|
||||
proto/generated/compliance.pb.go exceeds 500 LOC because it is
|
||||
machine-generated and cannot be split. Added to loc-exceptions.txt
|
||||
with rationale.
|
||||
|
||||
[guardrail-change]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Architecture Rules (Non-Negotiable)
|
||||
|
||||
### File budget
|
||||
- **500 LOC hard cap** on every non-test, non-generated source file.
|
||||
- The `PreToolUse` hook in `.claude/settings.json` blocks Claude Code from creating or editing files that would breach this limit.
|
||||
- Exceptions require a written rationale in `.claude/rules/loc-exceptions.txt` plus `[guardrail-change]` in the commit.
|
||||
|
||||
### Clean architecture per service
|
||||
- Python (FastAPI): `api → services → repositories → db.models`. Handlers ≤ 30 LOC. See `AGENTS.python.md`.
|
||||
- Go (Gin): Standard Go Project Layout + hexagonal. `cmd/` is thin wiring. See `AGENTS.go.md`.
|
||||
- TypeScript (Next.js 15): server-first, push client boundary deep, colocate `_components/` + `_hooks/` per route. See `AGENTS.typescript.md`.
|
||||
|
||||
### 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 route path, HTTP 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/`) in the **same changeset**.
|
||||
- OpenAPI baseline snapshots live in `tests/contracts/`. Contract tests fail on any drift.
|
||||
|
||||
---
|
||||
|
||||
## 6. Pull Requests
|
||||
|
||||
- **Target branch: `main`** — squash merge your feature branch into `main`.
|
||||
- Keep PRs focused; one logical change per PR.
|
||||
|
||||
**PR checklist before requesting review:**
|
||||
|
||||
- [ ] `bash scripts/check-loc.sh` exits 0
|
||||
- [ ] All lint checks pass (go, python, tsc)
|
||||
- [ ] All tests pass locally
|
||||
- [ ] No endpoint drift without consumer updates in the same PR
|
||||
- [ ] `[guardrail-change]` present in commit message if guardrail files were touched
|
||||
- [ ] Docs updated if new endpoints, config vars, or architecture changed
|
||||
|
||||
---
|
||||
|
||||
## 7. Claude Code Users
|
||||
|
||||
This section is for AI-assisted development sessions using Claude Code.
|
||||
|
||||
- **Always work on a feature branch** (`feat/*`, `feature/*`, `hotfix/*`), never directly on `main`.
|
||||
- The `.claude/settings.json` `PreToolUse` hooks will automatically block Write/Edit operations on files that would exceed 500 lines. This is intentional — split the file instead.
|
||||
- If the `guardrail-integrity` CI job fails, check that your commit message body includes `[guardrail-change]`. Add it and amend or create a fixup commit.
|
||||
- **Never use `git add -A` or `git add .`** — always stage specific files by path to avoid accidentally committing `.env`, `node_modules/`, `.next/`, or compiled binaries.
|
||||
- After every session: `bash scripts/check-loc.sh` must exit 0 before pushing.
|
||||
- Read `CLAUDE.md` and the relevant `AGENTS.<lang>.md` before starting work on a service.
|
||||
128
README.md
Normal file
128
README.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# breakpilot-compliance
|
||||
|
||||
**DSGVO/AI-Act compliance platform — 10 services, Go · Python · TypeScript**
|
||||
|
||||
[](https://gitea.meghsakha.com/Benjamin_Boenisch/breakpilot-compliance/actions)
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
breakpilot-compliance is a multi-tenant DSGVO/EU AI Act compliance platform that provides an SDK for consent management, data subject requests (DSR), audit logging, iACE impact assessments, and document archival. It ships as 10 containerised services covering an admin dashboard, a developer portal, a Python/FastAPI backend, a Go AI compliance engine, TTS, and a decentralised document store on IPFS. Every service is deployed automatically via Gitea Actions → Orca on every push to `main`.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
| Service | Tech | Port | Container |
|
||||
|---------|------|------|-----------|
|
||||
| admin-compliance | Next.js 15 | 3007 | bp-compliance-admin |
|
||||
| backend-compliance | Python / FastAPI 0.123 | 8002 | bp-compliance-backend |
|
||||
| ai-compliance-sdk | Go 1.24 / Gin | 8093 | bp-compliance-ai-sdk |
|
||||
| developer-portal | Next.js 15 | 3006 | bp-compliance-developer-portal |
|
||||
| breakpilot-compliance-sdk | TypeScript SDK (React/Vue/Angular/vanilla) | — | — |
|
||||
| consent-sdk | JS/TS Consent SDK | — | — |
|
||||
| compliance-tts-service | Python / Piper TTS | 8095 | bp-compliance-tts |
|
||||
| document-crawler | Python / FastAPI | 8098 | bp-compliance-document-crawler |
|
||||
| dsms-gateway | Python / FastAPI / IPFS | 8082 | bp-compliance-dsms-gateway |
|
||||
| dsms-node | IPFS Kubo v0.24.0 | — | bp-compliance-dsms-node |
|
||||
|
||||
All containers share the external `breakpilot-network` Docker network and depend on `breakpilot-core` (Valkey, Vault, RAG service, Nginx reverse proxy).
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
**Prerequisites:** Docker, Go 1.24+, Python 3.12+, Node.js 20+
|
||||
|
||||
```bash
|
||||
git clone ssh://git@gitea.meghsakha.com:22222/Benjamin_Boenisch/breakpilot-compliance.git
|
||||
cd breakpilot-compliance
|
||||
|
||||
# Copy and populate secrets (never commit .env)
|
||||
cp .env.example .env
|
||||
|
||||
# Start all services
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
For the Orca/Hetzner production target (x86_64), use the override:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.yml -f docker-compose.hetzner.yml up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Development Workflow
|
||||
|
||||
Use feature branches off `main`. Supported prefixes: `feat/`, `feature/`, `hotfix/`.
|
||||
|
||||
```bash
|
||||
git checkout main && git pull origin main
|
||||
git checkout -b feat/my-change
|
||||
# ... make changes ...
|
||||
git push origin feat/my-change
|
||||
# Open a PR → squash merge to main
|
||||
```
|
||||
|
||||
Push to `main` triggers:
|
||||
1. **Gitea Actions** — lint → test → validate (see CI Pipeline below)
|
||||
2. **Orca** — automatic build + deploy (~3 min total)
|
||||
|
||||
Monitor status: <https://gitea.meghsakha.com/Benjamin_Boenisch/breakpilot-compliance/actions>
|
||||
|
||||
---
|
||||
|
||||
## CI Pipeline
|
||||
|
||||
Defined in `.gitea/workflows/ci.yaml`.
|
||||
|
||||
| Job | What it checks |
|
||||
|-----|----------------|
|
||||
| `loc-budget` | All source files ≤ 500 LOC; soft target 300 |
|
||||
| `guardrail-integrity` | Commits touching guardrail files carry `[guardrail-change]` |
|
||||
| `go-lint` | `golangci-lint` on `ai-compliance-sdk/` |
|
||||
| `python-lint` | `ruff` + `mypy` on Python services |
|
||||
| `nodejs-lint` | `tsc --noEmit` + ESLint on Next.js services |
|
||||
| `test-go-ai-compliance` | `go test ./...` in `ai-compliance-sdk/` |
|
||||
| `test-python-backend-compliance` | `pytest` in `backend-compliance/` |
|
||||
| `test-python-document-crawler` | `pytest` in `document-crawler/` |
|
||||
| `test-python-dsms-gateway` | `pytest test_main.py` in `dsms-gateway/` |
|
||||
| `sbom-scan` | License + vulnerability scan via `syft` + `grype` |
|
||||
| `validate-canonical-controls` | OpenAPI contract baseline diff |
|
||||
|
||||
---
|
||||
|
||||
## File Budget
|
||||
|
||||
| Limit | Value | How to check |
|
||||
|-------|-------|--------------|
|
||||
| Soft target | 300 LOC | `bash scripts/check-loc.sh` |
|
||||
| Hard cap | 500 LOC | Same; also enforced by `PreToolUse` hook + git pre-commit + CI |
|
||||
| Exceptions | `.claude/rules/loc-exceptions.txt` | Require written rationale + `[guardrail-change]` commit marker |
|
||||
|
||||
The `.claude/settings.json` `PreToolUse` hook blocks Claude Code from writing or editing files that would exceed the hard cap. The git pre-commit hook re-checks. CI is the final gate.
|
||||
|
||||
---
|
||||
|
||||
## Links
|
||||
|
||||
| | URL |
|
||||
|-|-----|
|
||||
| Admin dashboard | <https://admin-dev.breakpilot.ai> |
|
||||
| Developer portal | <https://developers-dev.breakpilot.ai> |
|
||||
| Backend API | <https://api-dev.breakpilot.ai> |
|
||||
| AI SDK API | <https://sdk-dev.breakpilot.ai> |
|
||||
| Gitea repo | <https://gitea.meghsakha.com/Benjamin_Boenisch/breakpilot-compliance> |
|
||||
| Gitea Actions | <https://gitea.meghsakha.com/Benjamin_Boenisch/breakpilot-compliance/actions> |
|
||||
|
||||
915
REFACTOR_PLAYBOOK.md
Normal file
915
REFACTOR_PLAYBOOK.md
Normal file
@@ -0,0 +1,915 @@
|
||||
|
||||
---
|
||||
|
||||
## 1.9 `AGENTS.python.md` — Python / FastAPI conventions
|
||||
|
||||
```markdown
|
||||
# AGENTS.python.md — Python Service Conventions
|
||||
|
||||
## Layered architecture (FastAPI)
|
||||
|
||||
|
||||
## 1. Guardrail files (drop these in first)
|
||||
|
||||
These artifacts enforce the rules without you or Claude having to remember them. Install them as **Phase 0**, before touching any real code.
|
||||
|
||||
### 1.1 `.claude/CLAUDE.md` — loaded into every Claude session
|
||||
|
||||
```markdown
|
||||
# <Your Project Name>
|
||||
|
||||
> **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 migrations, no `ALTER TABLE`, no model field renames without an explicit migration plan reviewed by the DB owner.
|
||||
> 4. **Public endpoints are a contract.** Any change to a path/method/status/schema in a backend must be accompanied by a matching update in **every** consumer. OpenAPI snapshot tests in `tests/contracts/` are 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.
|
||||
```
|
||||
|
||||
Keep project-specific notes (dev environment, URLs, tech stack) under this header.
|
||||
|
||||
### 1.2 `.claude/settings.json` — PreToolUse LOC hook
|
||||
|
||||
First line of defense. Blocks Write/Edit operations that would create or push a file past 500 lines. This stops Claude from ever producing oversized files.
|
||||
|
||||
```json
|
||||
{
|
||||
"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\":\"guardrail: file exceeds the 500-line hard cap. Split it into smaller modules per the layering rules in AGENTS.<lang>.md.\"}'; 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\\\":\\\"guardrail: this edit would push $f to ~$proj lines (hard cap is 500). Split the file before continuing.\\\"}\"; fi; exit 0",
|
||||
"shell": "bash",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 1.3 `.claude/rules/architecture.md` — auto-loaded architecture rule
|
||||
|
||||
```markdown
|
||||
# Architecture Rules (auto-loaded)
|
||||
|
||||
Non-negotiable. Applied to every Claude Code session in this repo.
|
||||
|
||||
## File-size budget
|
||||
- **Soft target:** 300 lines. **Hard cap:** 500 lines.
|
||||
- Enforced by PreToolUse hook, pre-commit hook, and CI.
|
||||
- Exceptions live in `.claude/rules/loc-exceptions.txt` and require `[guardrail-change]` in the commit message. This list should SHRINK over time.
|
||||
|
||||
## Clean architecture
|
||||
- Python: see `AGENTS.python.md`. Layering: api → services → repositories → db.models.
|
||||
- Go: see `AGENTS.go.md`. Standard Go Project Layout + hexagonal.
|
||||
- TypeScript: see `AGENTS.typescript.md`. Server-by-default, push client boundary deep, colocate `_components/` and `_hooks/` per route.
|
||||
|
||||
## Database is frozen
|
||||
- No new migrations. No `ALTER TABLE`. No column renames.
|
||||
- Pre-commit hook blocks any change under `migrations/` unless commit message contains `[migration-approved]`.
|
||||
|
||||
## Public endpoints are a contract
|
||||
- Any change to path/method/status/schema must update every consumer in the same change set.
|
||||
- OpenAPI baseline at `tests/contracts/openapi.baseline.json`. Contract tests fail on drift.
|
||||
|
||||
## Tests
|
||||
- New code without tests fails CI.
|
||||
- Refactors preserve coverage. Before splitting an oversized file, add a characterization test pinning current behavior.
|
||||
- Layout: `tests/unit/`, `tests/integration/`, `tests/contracts/`, `tests/e2e/`.
|
||||
|
||||
## Guardrails are 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.
|
||||
- If Claude thinks a rule is wrong, surface it to the user. Do not silently weaken.
|
||||
|
||||
## Tooling baseline
|
||||
- Python: `ruff`, `mypy --strict` on new modules, `pytest --cov`.
|
||||
- Go: `golangci-lint` strict, `go vet`, table-driven tests.
|
||||
- TS: `tsc --noEmit` strict, ESLint type-aware, Vitest, Playwright.
|
||||
- All: dependency caching in CI, license/SBOM scan via `syft`+`grype`.
|
||||
```
|
||||
|
||||
### 1.4 `.claude/rules/loc-exceptions.txt`
|
||||
|
||||
```
|
||||
# loc-exceptions.txt — files allowed to exceed the 500-line hard cap.
|
||||
#
|
||||
# Format: one repo-relative path per line. Comments start with '#'.
|
||||
# Each exception MUST be preceded by a comment explaining why splitting is not viable.
|
||||
# Goal: this list SHRINKS over time.
|
||||
|
||||
# --- Example entries ---
|
||||
# Static data catalogs — splitting fragments lookup tables without improving readability.
|
||||
# src/catalogs/country-data.ts
|
||||
# src/catalogs/industry-taxonomy.ts
|
||||
|
||||
# Generated files — regenerated from schemas.
|
||||
# api/generated/types.ts
|
||||
```
|
||||
|
||||
|
||||
### 1.5 `scripts/check-loc.sh`
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
# check-loc.sh — File-size budget enforcer. Soft: 300. Hard: 500.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/check-loc.sh # scan whole repo
|
||||
# scripts/check-loc.sh --changed # only files changed vs origin/main
|
||||
# scripts/check-loc.sh path/to/file.py # check specific files
|
||||
# scripts/check-loc.sh --json # machine-readable output
|
||||
# Exit codes: 0 clean, 1 hard violation, 2 bad invocation.
|
||||
|
||||
set -euo pipefail
|
||||
SOFT=300
|
||||
HARD=500
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
EXCEPTIONS_FILE="$REPO_ROOT/.claude/rules/loc-exceptions.txt"
|
||||
|
||||
CHANGED_ONLY=0; JSON=0; TARGETS=()
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--changed) CHANGED_ONLY=1 ;;
|
||||
--json) JSON=1 ;;
|
||||
-h|--help) sed -n '2,10p' "$0"; exit 0 ;;
|
||||
-*) echo "unknown flag: $arg" >&2; exit 2 ;;
|
||||
*) TARGETS+=("$arg") ;;
|
||||
esac
|
||||
done
|
||||
|
||||
is_excluded() {
|
||||
local f="$1"
|
||||
case "$f" in
|
||||
*/node_modules/*|*/.next/*|*/.git/*|*/dist/*|*/build/*|*/__pycache__/*|*/vendor/*) return 0 ;;
|
||||
*/migrations/*|*/alembic/versions/*) return 0 ;;
|
||||
*_test.go|*.test.ts|*.test.tsx|*.spec.ts|*.spec.tsx) return 0 ;;
|
||||
*/tests/*|*/test/*) return 0 ;;
|
||||
*.md|*.json|*.yaml|*.yml|*.lock|*.sum|*.mod|*.toml|*.cfg|*.ini) return 0 ;;
|
||||
*.svg|*.png|*.jpg|*.jpeg|*.gif|*.ico|*.pdf|*.woff|*.woff2|*.ttf) return 0 ;;
|
||||
*.generated.*|*.gen.*|*_pb.go|*_pb2.py|*.pb.go) return 0 ;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
is_in_exceptions() {
|
||||
[[ -f "$EXCEPTIONS_FILE" ]] || return 1
|
||||
local rel="${1#$REPO_ROOT/}"
|
||||
grep -Fxq "$rel" "$EXCEPTIONS_FILE"
|
||||
}
|
||||
collect_targets() {
|
||||
if (( ${#TARGETS[@]} > 0 )); then printf '%s\n' "${TARGETS[@]}"
|
||||
elif (( CHANGED_ONLY )); then
|
||||
git -C "$REPO_ROOT" diff --name-only --diff-filter=AM origin/main...HEAD 2>/dev/null \
|
||||
|| git -C "$REPO_ROOT" diff --name-only --diff-filter=AM HEAD
|
||||
else git -C "$REPO_ROOT" ls-files; fi
|
||||
}
|
||||
|
||||
violations_hard=(); violations_soft=()
|
||||
while IFS= read -r f; do
|
||||
[[ -z "$f" ]] && continue
|
||||
abs="$f"; [[ "$abs" != /* ]] && abs="$REPO_ROOT/$f"
|
||||
[[ -f "$abs" ]] || continue
|
||||
is_excluded "$abs" && continue
|
||||
is_in_exceptions "$abs" && continue
|
||||
loc=$(wc -l < "$abs" | tr -d ' ')
|
||||
if (( loc > HARD )); then violations_hard+=("$loc $f")
|
||||
elif (( loc > SOFT )); then violations_soft+=("$loc $f"); fi
|
||||
done < <(collect_targets)
|
||||
|
||||
if (( JSON )); then
|
||||
printf '{"hard":['
|
||||
first=1; for v in "${violations_hard[@]}"; do
|
||||
loc="${v%% *}"; path="${v#* }"
|
||||
(( first )) || printf ','; first=0
|
||||
printf '{"loc":%s,"path":"%s"}' "$loc" "$path"
|
||||
done
|
||||
printf '],"soft":['
|
||||
first=1; for v in "${violations_soft[@]}"; do
|
||||
loc="${v%% *}"; path="${v#* }"
|
||||
(( first )) || printf ','; first=0
|
||||
printf '{"loc":%s,"path":"%s"}' "$loc" "$path"
|
||||
done
|
||||
printf ']}\n'
|
||||
else
|
||||
if (( ${#violations_soft[@]} > 0 )); then
|
||||
echo "::warning:: $((${#violations_soft[@]})) file(s) exceed soft target ($SOFT lines):"
|
||||
printf ' %s\n' "${violations_soft[@]}" | sort -rn
|
||||
fi
|
||||
if (( ${#violations_hard[@]} > 0 )); then
|
||||
echo "::error:: $((${#violations_hard[@]})) file(s) exceed HARD CAP ($HARD lines) — split required:"
|
||||
printf ' %s\n' "${violations_hard[@]}" | sort -rn
|
||||
fi
|
||||
fi
|
||||
(( ${#violations_hard[@]} == 0 ))
|
||||
```
|
||||
|
||||
Make executable: `chmod +x scripts/check-loc.sh`.
|
||||
|
||||
### 1.6 `scripts/githooks/pre-commit`
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
# pre-commit — enforces structural guardrails.
|
||||
#
|
||||
# 1. Blocks commits that introduce a non-test, non-generated source file > 500 LOC.
|
||||
# 2. Blocks commits touching migrations/ unless commit message contains [migration-approved].
|
||||
# 3. Blocks edits to guardrail files unless [guardrail-change] is in the commit message.
|
||||
|
||||
set -euo pipefail
|
||||
REPO_ROOT="$(git rev-parse --show-toplevel)"
|
||||
|
||||
mapfile -t staged < <(git diff --cached --name-only --diff-filter=ACM)
|
||||
[[ ${#staged[@]} -eq 0 ]] && exit 0
|
||||
|
||||
# 1. LOC budget on staged files.
|
||||
loc_targets=()
|
||||
for f in "${staged[@]}"; do
|
||||
[[ -f "$REPO_ROOT/$f" ]] && loc_targets+=("$REPO_ROOT/$f")
|
||||
done
|
||||
if [[ ${#loc_targets[@]} -gt 0 ]]; then
|
||||
if ! "$REPO_ROOT/scripts/check-loc.sh" "${loc_targets[@]}"; then
|
||||
echo; echo "Commit blocked: file-size budget violated."
|
||||
echo "Split the file (preferred) or add to .claude/rules/loc-exceptions.txt."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# 2. Migrations frozen unless approved.
|
||||
if printf '%s\n' "${staged[@]}" | grep -qE '(^|/)(migrations|alembic/versions)/'; then
|
||||
if ! grep -q '\[migration-approved\]' "$(git rev-parse --git-dir)/COMMIT_EDITMSG" 2>/dev/null; then
|
||||
echo "Commit blocked: this change touches a migrations directory."
|
||||
echo "Add '[migration-approved]' to your commit message if approved."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# 3. Guardrail files protected.
|
||||
guarded='^(\.claude/settings\.json|\.claude/rules/loc-exceptions\.txt|scripts/check-loc\.sh|scripts/githooks/pre-commit|AGENTS\.(python|go|typescript)\.md)$'
|
||||
if printf '%s\n' "${staged[@]}" | grep -qE "$guarded"; then
|
||||
if ! grep -q '\[guardrail-change\]' "$(git rev-parse --git-dir)/COMMIT_EDITMSG" 2>/dev/null; then
|
||||
echo "Commit blocked: this change modifies guardrail files."
|
||||
echo "Add '[guardrail-change]' to your commit message and explain why in the body."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
exit 0
|
||||
```
|
||||
|
||||
### 1.7 `scripts/install-hooks.sh`
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
# install-hooks.sh — installs git hooks that enforce repo guardrails locally.
|
||||
# Idempotent. Safe to re-run. Run once per clone: bash scripts/install-hooks.sh
|
||||
set -euo pipefail
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
HOOKS_DIR="$REPO_ROOT/.git/hooks"
|
||||
SRC_DIR="$REPO_ROOT/scripts/githooks"
|
||||
|
||||
[[ -d "$REPO_ROOT/.git" ]] || { echo "Not a git repository: $REPO_ROOT" >&2; exit 1; }
|
||||
mkdir -p "$HOOKS_DIR"
|
||||
for hook in pre-commit; do
|
||||
src="$SRC_DIR/$hook"; dst="$HOOKS_DIR/$hook"
|
||||
if [[ -f "$src" ]]; then cp "$src" "$dst"; chmod +x "$dst"; echo "installed: $dst"; fi
|
||||
done
|
||||
echo "Done. Hooks active for this clone."
|
||||
```
|
||||
|
||||
### 1.8 CI additions (`.github/workflows/ci.yaml` or `.gitea/workflows/ci.yaml`)
|
||||
|
||||
Add a `loc-budget` job that fails on hard violations:
|
||||
|
||||
```yaml
|
||||
jobs:
|
||||
loc-budget:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Check file-size budget
|
||||
run: bash scripts/check-loc.sh --changed
|
||||
|
||||
python-lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: ruff
|
||||
run: pip install ruff && ruff check .
|
||||
- name: mypy on new modules
|
||||
run: pip install mypy && mypy --strict services/ repositories/ domain/
|
||||
|
||||
go-lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v4
|
||||
with: { version: latest }
|
||||
|
||||
ts-lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: npm ci && npx tsc --noEmit && npx next build
|
||||
|
||||
contract-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: pytest tests/contracts/ -v
|
||||
|
||||
license-sbom-scan:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: anchore/sbom-action@v0
|
||||
- uses: anchore/scan-action@v3
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
### 1.9 `AGENTS.python.md` (Python / FastAPI)
|
||||
|
||||
````markdown
|
||||
# AGENTS.python.md — Python Service Conventions
|
||||
|
||||
## Layered architecture
|
||||
|
||||
```
|
||||
<service>/
|
||||
├── api/ # HTTP layer — routers only. Thin (≤30 LOC per handler).
|
||||
│ └── <domain>_routes.py
|
||||
├── services/ # Business logic. Pure-ish; no FastAPI imports.
|
||||
├── repositories/ # DB access. Owns SQLAlchemy session usage.
|
||||
├── domain/ # Value objects, enums, domain exceptions.
|
||||
├── schemas/ # Pydantic models, split per domain. Never one giant schemas.py.
|
||||
└── db/models/ # SQLAlchemy ORM, one module per aggregate. __tablename__ frozen.
|
||||
```
|
||||
|
||||
Dependency direction: `api → services → repositories → db.models`. Lower layers must not import upper.
|
||||
|
||||
## Routers
|
||||
- One `APIRouter` per domain file. Handlers ≤30 LOC.
|
||||
- Parse request → call service → map domain errors → return response model.
|
||||
- Inject services via `Depends`. No globals.
|
||||
|
||||
```python
|
||||
@router.post("/items", response_model=ItemRead, status_code=201)
|
||||
async def create_item(
|
||||
payload: ItemCreate,
|
||||
service: ItemService = Depends(get_item_service),
|
||||
tenant_id: UUID = Depends(get_tenant_id),
|
||||
) -> ItemRead:
|
||||
with translate_domain_errors():
|
||||
return await service.create(tenant_id, payload)
|
||||
```
|
||||
|
||||
## Domain errors + translator
|
||||
|
||||
```python
|
||||
# domain/errors.py
|
||||
class DomainError(Exception): ...
|
||||
class NotFoundError(DomainError): ...
|
||||
class ConflictError(DomainError): ...
|
||||
class ValidationError(DomainError): ...
|
||||
class PermissionError(DomainError): ...
|
||||
|
||||
# api/_http_errors.py
|
||||
from contextlib import contextmanager
|
||||
from fastapi import HTTPException
|
||||
|
||||
@contextmanager
|
||||
def translate_domain_errors():
|
||||
try: yield
|
||||
except NotFoundError as e: raise HTTPException(404, str(e)) from e
|
||||
except ConflictError as e: raise HTTPException(409, str(e)) from e
|
||||
except ValidationError as e: raise HTTPException(400, str(e)) from e
|
||||
except PermissionError as e: raise HTTPException(403, str(e)) from e
|
||||
```
|
||||
|
||||
## Services
|
||||
- Constructor takes repository interface, not concrete.
|
||||
- No FastAPI / HTTP knowledge.
|
||||
- Raise domain exceptions, never HTTPException.
|
||||
|
||||
## Repositories
|
||||
- Intent-named methods (`get_pending_for_tenant`), not CRUD-named (`select_where`).
|
||||
- Session injected. No business logic.
|
||||
- Return ORM models or domain VOs; never `Row`.
|
||||
|
||||
## Schemas (Pydantic v2)
|
||||
- One module per domain. ≤300 lines.
|
||||
- `model_config = ConfigDict(from_attributes=True, frozen=True)` for reads.
|
||||
- Separate `*Create`, `*Update`, `*Read`.
|
||||
|
||||
## Tests
|
||||
- `tests/unit/`, `tests/integration/`, `tests/contracts/`.
|
||||
- Unit tests mock repository via `AsyncMock`.
|
||||
- Integration tests use real Postgres from compose via transactional fixture (rollback per test).
|
||||
- Contract tests diff `/openapi.json` against `tests/contracts/openapi.baseline.json`.
|
||||
- Naming: `test_<unit>_<scenario>_<expected>.py::TestX::test_method`.
|
||||
- `pytest-asyncio` mode = `auto`. Coverage target: 80% new code.
|
||||
|
||||
## Tooling
|
||||
- `ruff check` + `ruff format` (line length 100).
|
||||
- `mypy --strict` on `services/`, `repositories/`, `domain/` first. Expand outward via per-module overrides in mypy.ini:
|
||||
|
||||
```ini
|
||||
[mypy]
|
||||
strict = True
|
||||
|
||||
[mypy-<service>.services.*]
|
||||
strict = True
|
||||
|
||||
[mypy-<service>.legacy.*]
|
||||
# Legacy modules not yet refactored — expand strictness over time.
|
||||
ignore_errors = True
|
||||
```
|
||||
|
||||
## What you may NOT do
|
||||
- Add a new migration.
|
||||
- Rename `__tablename__`, column, or enum value.
|
||||
- Change route contract without simultaneous consumer update.
|
||||
- Catch `Exception` broadly.
|
||||
- Put business logic in a router or a Pydantic validator.
|
||||
- Create a file > 500 lines.
|
||||
````
|
||||
|
||||
### 1.10 `AGENTS.go.md` (Go / Gin or chi)
|
||||
|
||||
````markdown
|
||||
# AGENTS.go.md — Go Service Conventions
|
||||
|
||||
## Layered architecture (Standard Go Project Layout + hexagonal)
|
||||
|
||||
```
|
||||
<service>/
|
||||
├── cmd/server/main.go # Thin: parse flags → app.New → app.Run. < 50 LOC.
|
||||
├── internal/
|
||||
│ ├── app/ # Wiring: config + DI + lifecycle.
|
||||
│ ├── domain/<aggregate>/ # Pure types, interfaces, errors. No I/O.
|
||||
│ ├── service/<aggregate>/ # Business logic. Depends on domain interfaces.
|
||||
│ ├── repository/postgres/<aggregate>/ # Concrete repos.
|
||||
│ ├── transport/http/
|
||||
│ │ ├── handler/<aggregate>/
|
||||
│ │ ├── middleware/
|
||||
│ │ └── router.go
|
||||
│ └── platform/ # DB pool, logger, config, tracing.
|
||||
└── pkg/ # Importable by other repos. Empty unless needed.
|
||||
```
|
||||
|
||||
Direction: `transport → service → domain ← repository`. `domain` imports no siblings.
|
||||
|
||||
## Handlers
|
||||
- ≤40 LOC. Bind → call service → map error via `httperr.Write(c, err)` → respond.
|
||||
|
||||
```go
|
||||
func (h *ItemHandler) Create(c *gin.Context) {
|
||||
var req CreateItemRequest
|
||||
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)
|
||||
}
|
||||
```
|
||||
|
||||
## Errors — single `httperr` package
|
||||
```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. Recovery middleware logs and returns 500.
|
||||
|
||||
## Services
|
||||
- Struct + constructor + interface methods. No package-level state.
|
||||
- `context.Context` first arg always.
|
||||
- Return `(value, error)`. Wrap with `fmt.Errorf("create item: %w", err)`.
|
||||
- Domain errors as sentinel vars or typed; match with `errors.Is` / `errors.As`.
|
||||
|
||||
## Repositories
|
||||
- Interface in `domain/<aggregate>/repository.go`. Impl in `repository/postgres/<aggregate>/`.
|
||||
- One file per query group; no file > 500 LOC.
|
||||
- `pgx`/`sqlc` over hand-rolled SQL. No ORM globals. Everything takes `ctx`.
|
||||
|
||||
## Tests
|
||||
- Co-located `*_test.go`. Table-driven for service logic.
|
||||
- Handlers via `httptest.NewRecorder`.
|
||||
- Repos via `testcontainers-go` (or the compose Postgres). Never mocks at SQL boundary.
|
||||
- Coverage target: 80% on `service/`.
|
||||
|
||||
## Tooling (`golangci-lint` strict config)
|
||||
- Linters: `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.
|
||||
|
||||
## What you may NOT do
|
||||
- Touch DB schema/migrations.
|
||||
- Add a new top-level package under `internal/` without review.
|
||||
- `import "C"`, unsafe, reflection-heavy code.
|
||||
- Non-trivial setup in `init()`. Wire in `internal/app`.
|
||||
- File > 500 lines.
|
||||
- Change route contract without updating consumers.
|
||||
````
|
||||
|
||||
### 1.11 `AGENTS.typescript.md` (TypeScript / Next.js)
|
||||
|
||||
````markdown
|
||||
# AGENTS.typescript.md — TypeScript / Next.js Conventions
|
||||
|
||||
## Layered architecture (Next.js 15 App Router)
|
||||
|
||||
```
|
||||
app/
|
||||
├── <route>/
|
||||
│ ├── page.tsx # Server Component by default. ≤200 LOC.
|
||||
│ ├── layout.tsx
|
||||
│ ├── _components/ # Private folder; colocated UI. 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, zod schemas. Reusable.
|
||||
└── server/<domain>/ # Server-only logic; uses "server-only".
|
||||
components/ # Truly shared, app-wide components.
|
||||
```
|
||||
|
||||
Server vs Client: default is Server Component. Add `"use client"` only when state/effects/browser APIs needed. Push client boundary as deep as possible.
|
||||
|
||||
## API routes (route.ts)
|
||||
- One handler per HTTP method, ≤40 LOC.
|
||||
- Validate with `zod`. Reject invalid → 400.
|
||||
- Delegate to `lib/server/<domain>/`.
|
||||
|
||||
```ts
|
||||
export async function POST(req: Request) {
|
||||
const parsed = CreateItemSchema.safeParse(await req.json());
|
||||
if (!parsed.success)
|
||||
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
||||
const result = await itemService.create(parsed.data);
|
||||
return NextResponse.json(result, { status: 201 });
|
||||
}
|
||||
```
|
||||
|
||||
## Page components
|
||||
- Pages > 300 lines → split into colocated `_components/`.
|
||||
- Server Components fetch data; pass plain objects to Client Components.
|
||||
- No data fetching in `useEffect` for server-renderable data.
|
||||
- State: prefer URL state (`searchParams`) + Server Components over global stores.
|
||||
|
||||
## Types — barrel re-export pattern for splitting monolithic type files
|
||||
|
||||
```ts
|
||||
// lib/sdk/types/index.ts
|
||||
export * from './enums'
|
||||
export * from './vendor'
|
||||
export * from './dsfa'
|
||||
// consumers still `import { Foo } from '@/lib/sdk/types'`
|
||||
```
|
||||
|
||||
Rules: no `any`. No `as unknown as`. All DTOs are zod schemas; infer via `z.infer`.
|
||||
|
||||
## Tests
|
||||
- Unit: **Vitest** (`*.test.ts`/`*.test.tsx`), colocated.
|
||||
- Hooks: `@testing-library/react` `renderHook`.
|
||||
- E2E: **Playwright** (`tests/e2e/`), one spec per top-level page minimum.
|
||||
- Coverage: 70% on `lib/`, smoke on `app/`.
|
||||
|
||||
## Tooling
|
||||
- `tsc --noEmit` clean (strict, `noUncheckedIndexedAccess: true`).
|
||||
- ESLint with `@typescript-eslint`, type-aware rules on.
|
||||
- `next build` clean. No `@ts-ignore`. `@ts-expect-error` only with a reason comment.
|
||||
|
||||
## What you may NOT do
|
||||
- Business logic in `page.tsx` or `route.ts`.
|
||||
- Cross-app module imports.
|
||||
- `dangerouslySetInnerHTML` without explicit sanitization.
|
||||
- Backend API calls from Client Components when a Server Component/Action would do.
|
||||
- Change route contract without updating consumers in the same change.
|
||||
- File > 500 lines.
|
||||
- Globally disable lint/type rules — fix the root cause.
|
||||
````
|
||||
|
||||
---
|
||||
|
||||
|
||||
## 2. Phase plan — behavior-preserving refactor
|
||||
|
||||
Work in phases. Each phase ends green (tests pass, build clean, contract baseline unchanged). Do **not** skip ahead.
|
||||
|
||||
### Phase 0 — Foundation (single PR, low risk)
|
||||
|
||||
**Goal:** Set up rails. No code refactors yet.
|
||||
|
||||
1. Drop in all files from Section 1. Install hooks: `bash scripts/install-hooks.sh`.
|
||||
2. Populate `.claude/rules/loc-exceptions.txt` with grandfathered entries (one line each, with a comment rationale) so CI doesn't fail day 1.
|
||||
3. Append the non-negotiable rules block to root `CLAUDE.md`.
|
||||
4. Add per-language `AGENTS.*.md` at repo root.
|
||||
5. Add the CI jobs from §1.8.
|
||||
6. Per-service `README.md` + `CLAUDE.md` stubs: what it does, run/test commands, layered architecture diagram, env vars, API surface link.
|
||||
|
||||
**Verification:** CI green; loc-budget job passes with allowlist; next Claude session loads the rules automatically.
|
||||
|
||||
### Phase 1 — Backend service (Python/FastAPI)
|
||||
|
||||
**Critical targets:** any `routes.py` / `schemas.py` / `repository.py` / `models.py` over 500 LOC.
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. **Snapshot the API contract:** `curl /openapi.json > tests/contracts/openapi.baseline.json`. Add a contract test that diffs current vs baseline and fails on any path/method/param drift.
|
||||
2. **Characterization tests first.** For each oversized route file, add `TestClient` tests exercising every endpoint (happy path + one error path). Use `httpx.AsyncClient` + factory fixtures.
|
||||
3. **Split models.py per aggregate.** Keep a shim: `from <service>.db.models import *` re-exports so existing imports keep working. One module per aggregate; `__tablename__` unchanged (no migration).
|
||||
4. **Split schemas.py** similarly with a re-export shim.
|
||||
5. **Extract service layer.** Each route handler delegates to a `*Service` class injected via `Depends`. Handlers shrink to ≤30 LOC.
|
||||
6. **Repository extraction** from the giant repository file; one class per aggregate.
|
||||
7. **`mypy --strict` scoped to new packages first.** Expand outward via `mypy.ini` per-module overrides.
|
||||
8. **Tests:** unit tests per service (mocked repo), repo tests against a transactional fixture (real Postgres), integration tests at API layer.
|
||||
|
||||
**Gotchas we hit:**
|
||||
- Tests that patch module-level symbols (e.g. `SessionLocal`, `scan_X`) break when you move logic behind `Depends`. Fix: re-export the symbol from the route module, or have the service lookup use the module-level symbol directly so the patch still takes effect.
|
||||
- `from __future__ import annotations` can break Pydantic TypeAdapter forward refs. Remove it where it conflicts.
|
||||
- Sibling test file status codes drift when you introduce the domain-error translator (e.g. 422 → 400). Update assertions in the same commit.
|
||||
|
||||
**Verification:** all pytest files green. Characterization tests green. Contract test green (no drift). `mypy` clean on new packages. Coverage ≥ baseline + 10%.
|
||||
|
||||
### Phase 2 — Go backend
|
||||
|
||||
**Critical targets:** any handler / store / rules file over 500 LOC.
|
||||
|
||||
**Steps:**
|
||||
1. OpenAPI/Swagger snapshot (or generate via `swag`) → contract tests.
|
||||
2. Generate handler-level tests with `httptest` for every endpoint pre-refactor.
|
||||
3. Define hexagonal layout (see AGENTS.go.md). Move incrementally with type aliases for back-compat where needed.
|
||||
4. Replace ad-hoc error handling with `errors.Is/As` + a single `httperr` package.
|
||||
5. Add `golangci-lint` strict config; fix new findings only (don't chase legacy lint).
|
||||
6. Table-driven service tests. `testcontainers-go` for repo layer.
|
||||
|
||||
**Verification:** `go test ./...` passes; `golangci-lint run` clean; contract tests green; no DB schema diff.
|
||||
|
||||
### Phase 3 — Frontend (Next.js)
|
||||
|
||||
**Biggest beast — expect this to dominate.** Critical targets: `page.tsx` / monolithic types / API routes over 500 LOC.
|
||||
|
||||
**Per oversized page:**
|
||||
1. Extract presentational components into `app/<route>/_components/` (private folder, Next.js convention).
|
||||
2. Move data fetching into Server Components / Server Actions; Client Components become small.
|
||||
3. Hooks → `app/<route>/_hooks/`.
|
||||
4. Pure helpers → `lib/<domain>/`.
|
||||
5. Add Vitest unit tests for hooks and pure helpers; Playwright smoke tests for each top-level page.
|
||||
|
||||
**Monolithic types file:** use barrel re-export pattern.
|
||||
- Create `types/` directory with domain files.
|
||||
- Create `types/index.ts` with `export * from './<domain>'` lines.
|
||||
- **Critical:** TypeScript won't allow both `types.ts` AND `types/index.ts` — delete the file, atomic swap to directory.
|
||||
|
||||
**API routes (`route.ts`):** same router→service split as backend. Each `route.ts` becomes a thin handler delegating to `lib/server/<domain>/`.
|
||||
|
||||
**Endpoint preservation:** if any internal route URL changes, grep every consumer (SDK packages, developer portal, sibling apps) and update in the same change.
|
||||
|
||||
**Gotchas:**
|
||||
- Pre-existing type bugs often surface when you try to build. Fix them as drive-by if they block your refactor; otherwise document in a separate follow-up.
|
||||
- `useClient` component imports from `'../provider'` that rely on re-exports: preserve the re-export or update importers in the same commit.
|
||||
- Next.js build can fail at page-manifest stage with unrelated prerender errors. Run `next build` fresh (not from cache) to see real status.
|
||||
|
||||
**Verification:** `next build` clean; `tsc --noEmit` clean; Playwright smoke tests pass; visual diff check on key pages (manual + screenshots in PR).
|
||||
|
||||
### Phase 4 — SDKs & smaller services
|
||||
|
||||
Apply the same patterns at smaller scale:
|
||||
- **SDK packages (0 tests):** add Vitest unit tests for public surface before/while splitting.
|
||||
- **Manager/Client classes:** extract config defaults, side-effect helpers (e.g. Google Consent Mode wiring), framework adapters into sibling files. Keep the main class as orchestration.
|
||||
- **Framework adapters (React/Vue/Angular):** each component/composable/service/module goes in its own sibling file; the entry `index.ts` is a thin barrel of re-exports.
|
||||
- **Doc monoliths (`index.md` thousands of lines):** split per topic with mkdocs nav.
|
||||
|
||||
### Phase 5 — CI hardening & governance
|
||||
|
||||
1. Promote `loc-budget` from warning → blocking once the allowlist has drained to legitimate exceptions only.
|
||||
2. Add mutation testing in nightly (`mutmut` for Python, `gomutesting` for Go).
|
||||
3. Add `dependabot`/`renovate` for npm + pip + go mod.
|
||||
4. Add release tagging workflow.
|
||||
5. Write ADRs (`docs/adr/`) capturing the architecture decisions from phases 1–3.
|
||||
6. Distill recurring patterns into `.claude/rules/` updates.
|
||||
|
||||
---
|
||||
|
||||
## 3. Agent prompt templates
|
||||
|
||||
When the work volume is big, parallelize with subagents. These prompts were battle-tested in practice.
|
||||
|
||||
### 3.1 Backend route file split (Python)
|
||||
|
||||
> You are working in `<repo>` on branch `<branch>`. Every source file must be under 500 LOC (hard cap enforced by a PreToolUse hook); soft target 300.
|
||||
>
|
||||
> **Task:** split `<path/to/file>_routes.py` (NNN LOC) following the router → service → repository layering described in `AGENTS.python.md`.
|
||||
>
|
||||
> **Steps:**
|
||||
> 1. Snapshot the relevant slice of `/openapi.json` and add a contract test that pins current behavior.
|
||||
> 2. Add characterization tests for every endpoint in this file (happy path + one error path) using `httpx.AsyncClient`.
|
||||
> 3. Extract each route handler's business logic into a `<domain>Service` class in `<service>/services/<domain>_service.py`. Inject via `Depends(get_<domain>_service)`.
|
||||
> 4. Raise domain errors (`NotFoundError`, `ConflictError`, `ValidationError`), never `HTTPException`. Use the `translate_domain_errors()` context manager in handlers.
|
||||
> 5. Move DB access to `<service>/repositories/<domain>_repository.py`. Session injected.
|
||||
> 6. Split Pydantic schemas from the giant `schemas.py` into `<service>/schemas/<domain>.py` if >300 lines.
|
||||
>
|
||||
> **Constraints:**
|
||||
> - Behavior preservation. No route rename/method/status/schema changes.
|
||||
> - Tests that patch module-level symbols must keep working — re-export the symbol or refactor the lookup so the patch still takes effect.
|
||||
> - Run `pytest` after each step. Commit each file as its own commit.
|
||||
> - Push at end: `git push origin <branch>`.
|
||||
>
|
||||
> When done, report: (a) new LOC counts, (b) test results, (c) mypy status, (d) commit SHAs. Under 300 words.
|
||||
|
||||
### 3.2 Go handler file split
|
||||
|
||||
> You are working in `<repo>` on branch `<branch>`. Hard cap 500 LOC.
|
||||
>
|
||||
> **Task:** split `<path>/handlers/<domain>_handler.go` (NNN LOC) into a hexagonal layout per `AGENTS.go.md`.
|
||||
>
|
||||
> **Steps:**
|
||||
> 1. Add `httptest` tests for every endpoint pre-refactor.
|
||||
> 2. Define `internal/domain/<aggregate>/` with types + interfaces + sentinel errors.
|
||||
> 3. Create `internal/service/<aggregate>/` with business logic implementing domain interfaces.
|
||||
> 4. Create `internal/repository/postgres/<aggregate>/` splitting queries by group.
|
||||
> 5. Thin handlers under `internal/transport/http/handler/<aggregate>/`. Each handler ≤40 LOC. Error mapping via `internal/platform/httperr`.
|
||||
> 6. Use `errors.Is` / `errors.As` for domain error matching.
|
||||
>
|
||||
> **Constraints:**
|
||||
> - No DB schema change.
|
||||
> - Table-driven service tests. `testcontainers-go` (or compose Postgres) for repo tests.
|
||||
> - `golangci-lint run` clean.
|
||||
>
|
||||
> Report new LOC, test status, lint status, commit SHAs. Under 300 words.
|
||||
|
||||
### 3.3 Next.js page split (the one we parallelized heavily)
|
||||
|
||||
> You are working in `<repo>` on branch `<branch>`. Every source file must be under 500 LOC (hard cap enforced by a PreToolUse hook); soft target 300. Other agents are working on OTHER pages in parallel — stay in your lane.
|
||||
>
|
||||
> **Task:** split the following Next.js 15 App Router client pages into colocated components so each `page.tsx` drops below 500 LOC.
|
||||
>
|
||||
> 1. `admin-compliance/app/sdk/<page-a>/page.tsx` (NNNN LOC)
|
||||
> 2. `admin-compliance/app/sdk/<page-b>/page.tsx` (NNNN LOC)
|
||||
>
|
||||
> **Pattern** (reference `admin-compliance/app/sdk/<already-split-example>/` for "done"):
|
||||
> - Create `_components/` subdirectory (Next.js private folder, won't create routes).
|
||||
> - Extract each logically-grouped section (forms, tables, modals, tabs, headers, cards) into its own component file. Name files after the component.
|
||||
> - Create `_hooks/` for custom hooks that were inline.
|
||||
> - Create `_types.ts` or `_data.ts` for hoisted types or data arrays.
|
||||
> - Remaining `page.tsx` wires extracted pieces — aim for under 300 LOC, hard cap 500.
|
||||
> - Preserve `'use client'` when present on original.
|
||||
> - DO NOT rename any exports that other files import. Grep first before moving.
|
||||
>
|
||||
> **Constraints:**
|
||||
> - Behavior preservation. No logic changes, no improvements.
|
||||
> - Imports must resolve (relative `./_components/Foo`).
|
||||
> - Run `cd admin-compliance && npx next build` after each file is done. Don't commit broken builds.
|
||||
> - DO NOT edit `.claude/settings.json`, `scripts/check-loc.sh`, `loc-exceptions.txt`, or any `AGENTS.*.md`.
|
||||
> - Commit each page as its own commit: `refactor(admin): split <name> page.tsx into colocated components`. HEREDOC body, include `Co-Authored-By:` trailer.
|
||||
> - Pull before push: `git pull --rebase origin <branch>`, then `git push origin <branch>`.
|
||||
>
|
||||
> **Coordination:** DO NOT touch `<list of pages other agents own>`. You own only `<your pages>`.
|
||||
>
|
||||
> When done, report: (a) each file's new LOC count, (b) how many `_components` were created, (c) whether `next build` is clean, (d) commit SHAs. Under 300 words.
|
||||
>
|
||||
> If the LOC hook blocks a Write, split further. If you hit rate limits partway, commit what's done and report progress honestly.
|
||||
|
||||
### 3.4 Monolithic types file split (TypeScript)
|
||||
|
||||
> `<repo>`, branch `<branch>`. Hard cap 500 LOC.
|
||||
>
|
||||
> **Task:** split `<lib>/types.ts` (NNNN LOC) into per-domain modules under `<lib>/types/`.
|
||||
>
|
||||
> **Steps:**
|
||||
> 1. Identify domain groupings (enums, API DTOs, one group per business aggregate).
|
||||
> 2. Create `<lib>/types/` directory with `<domain>.ts` files.
|
||||
> 3. Create `<lib>/types/index.ts` barrel: `export * from './<domain>'` per file.
|
||||
> 4. **Atomic swap:** delete the old `types.ts` in the same commit as the new `types/` directory. TypeScript won't resolve both a file and a directory with the same stem.
|
||||
> 5. Grep every consumer — imports from `'<lib>/types'` should still work via the barrel. No consumer file changes needed unless there's a name collision.
|
||||
> 6. Resolve collisions by renaming the less-canonical export (e.g. if two modules both export `LegalDocument`, rename the RAG one to `RagLegalDocument`).
|
||||
>
|
||||
> **Verification:** `tsc --noEmit` clean, `next build` clean.
|
||||
>
|
||||
> Report new LOC per file, collisions resolved, consumer updates, commit SHAs.
|
||||
|
||||
### 3.5 Agent orchestration rules (from hard-won experience)
|
||||
|
||||
When you spawn multiple agents in parallel:
|
||||
|
||||
1. **Own disjoint paths.** Give each agent a bounded list of files under specific directories. Spell out the "do NOT touch" list explicitly.
|
||||
2. **Always instruct `git pull --rebase origin <branch>` before push.** Agents running in parallel will push and cause non-fast-forward rejects without this.
|
||||
3. **Instruct `commit each file as its own commit`** — not a single mega-commit. Makes revert surgical.
|
||||
4. **Ask for concise reports (≤300 words):** new LOC counts, component counts, build status, commit SHAs.
|
||||
5. **Tell them to commit partial progress on rate-limit.** If they don't, their partial work lives in the working tree and you have to chase it with `git status` after. (We hit this — 4 agents silently left uncommitted work.)
|
||||
6. **Don't give an agent more than 2 big files at once.** Each page-split in practice took ~10–20 minutes + ~150k tokens. Two is a comfortable batch.
|
||||
7. **Reference a prior "done" example.** Commit SHAs are gold — the agent can inspect exactly the style you want.
|
||||
8. **Run one final `next build` / `pytest` / `go test` yourself after all agents finish.** Agent reports of "build clean" can be scoped (e.g. only their files); you want the whole-repo gate.
|
||||
|
||||
---
|
||||
|
||||
## 4. Workflow loop (per file)
|
||||
|
||||
```
|
||||
1. Read the oversized file end to end. Identify 3–6 extraction sections.
|
||||
2. Write characterization test (if backend) — pin behavior.
|
||||
3. Create the sibling files one at a time.
|
||||
- If the PreToolUse hook blocks (file still > 500), split further.
|
||||
4. Edit the root file: replace extracted bodies with imports + delegations.
|
||||
5. Run the full verification: pytest / next build / go test.
|
||||
6. Run LOC check: scripts/check-loc.sh <changed files>
|
||||
7. Commit with a scoped message and a 1–2 line body explaining why.
|
||||
8. Push.
|
||||
```
|
||||
|
||||
## 5. Commit message conventions
|
||||
|
||||
```
|
||||
refactor(<area>): <one-line what, not how>
|
||||
|
||||
<optional 1-3 sentence body: what split changed + verification result>
|
||||
<LOC table: before → after per file>
|
||||
<non-behavior changes flagged as drive-by fixes, with reason>
|
||||
|
||||
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
||||
```
|
||||
|
||||
Markers that unlock pre-commit guards:
|
||||
- `[migration-approved]` — allows changes under `migrations/` / `alembic/versions/`.
|
||||
- `[guardrail-change]` — allows changes to `.claude/settings.json`, `.claude/rules/loc-exceptions.txt`, `scripts/check-loc.sh`, `scripts/githooks/pre-commit`, or any `AGENTS.*.md`.
|
||||
|
||||
Good examples from our session:
|
||||
- `refactor(consent-sdk): split ConsentManager + framework adapters under 500 LOC`
|
||||
- `refactor(compliance-sdk): split client/provider/embed/state under 500 LOC`
|
||||
- `refactor(admin): split whistleblower page.tsx + restore scope helpers`
|
||||
- `chore: document data-catalog + legacy-service LOC exceptions` (with `[guardrail-change]` body)
|
||||
|
||||
## 6. Verification commands cheatsheet
|
||||
|
||||
```bash
|
||||
# LOC budget
|
||||
scripts/check-loc.sh --changed # only changed files
|
||||
scripts/check-loc.sh # whole repo
|
||||
scripts/check-loc.sh --json # for CI parsing
|
||||
|
||||
# Python
|
||||
pytest --cov=<package> --cov-report=term-missing
|
||||
ruff check .
|
||||
mypy --strict <package>/services <package>/repositories
|
||||
|
||||
# Go
|
||||
go test ./... -cover
|
||||
golangci-lint run
|
||||
go vet ./...
|
||||
|
||||
# TypeScript
|
||||
npx tsc --noEmit
|
||||
npx next build # from the Next.js app dir
|
||||
npm test -- --run # vitest one-shot
|
||||
npx playwright test tests/e2e # e2e smoke
|
||||
|
||||
# Contracts
|
||||
pytest tests/contracts/ # OpenAPI snapshot diff
|
||||
```
|
||||
|
||||
## 7. Out of scope (don't drift)
|
||||
|
||||
- DB schema / migrations — unless separate green-lit plan.
|
||||
- New features. This is a refactor.
|
||||
- Public endpoint renames without simultaneous consumer fix-up (exception: intra-monorepo URLs when you do the grep sweep).
|
||||
- Unrelated dead code cleanup — do it in a separate PR.
|
||||
- Bundling refactors across services in one commit — one service = one commit.
|
||||
|
||||
## 8. Memory / session handoff
|
||||
|
||||
If using Claude Code with persistent memory, save a `project_refactor_status.md` in your memory store after each phase:
|
||||
- What's done (files split, LOC before → after).
|
||||
- What's in progress (current file, blocker if any).
|
||||
- What's deferred (pre-existing bugs surfaced but left for follow-up).
|
||||
- Key patterns established (so next session doesn't rediscover them).
|
||||
|
||||
This lets you resume after context compacts or after rate-limit windows without losing the thread.
|
||||
|
||||
---
|
||||
|
||||
That's the whole methodology. Install Section 1, follow Section 2 phase-by-phase, use Section 3 to parallelize the grind. The guardrails do the policing so you don't have to remember anything.
|
||||
|
||||
@@ -20,11 +20,9 @@ edu-search-service
|
||||
school-service
|
||||
voice-service
|
||||
geo-service
|
||||
klausur-service
|
||||
studio-v2
|
||||
website
|
||||
scripts
|
||||
agent-core
|
||||
pca-platform
|
||||
breakpilot-drive
|
||||
breakpilot-compliance-sdk
|
||||
|
||||
@@ -16,13 +16,14 @@ COPY . .
|
||||
ARG NEXT_PUBLIC_API_URL
|
||||
ARG NEXT_PUBLIC_OLD_ADMIN_URL
|
||||
ARG NEXT_PUBLIC_SDK_URL
|
||||
ARG NEXT_PUBLIC_KLAUSUR_SERVICE_URL
|
||||
|
||||
# Set environment variables for build
|
||||
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
|
||||
ENV NEXT_PUBLIC_OLD_ADMIN_URL=$NEXT_PUBLIC_OLD_ADMIN_URL
|
||||
ENV NEXT_PUBLIC_SDK_URL=$NEXT_PUBLIC_SDK_URL
|
||||
ENV NEXT_PUBLIC_KLAUSUR_SERVICE_URL=$NEXT_PUBLIC_KLAUSUR_SERVICE_URL
|
||||
|
||||
# Ensure public directory exists (Next.js standalone needs it)
|
||||
RUN mkdir -p public
|
||||
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
@@ -36,13 +37,14 @@ WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
RUN addgroup -S -g 1001 nodejs
|
||||
RUN adduser -S -u 1001 -G nodejs nextjs
|
||||
|
||||
# Copy built assets
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/agent-core ./agent-core
|
||||
|
||||
# Switch to non-root user
|
||||
USER nextjs
|
||||
|
||||
53
admin-compliance/README.md
Normal file
53
admin-compliance/README.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# 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 (Phase 3 — in progress)
|
||||
|
||||
```
|
||||
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
|
||||
|
||||
- `lib/sdk/types.ts` has been split: it is now a barrel re-export to `lib/sdk/types/` (12 domain files: enums, company-profile, sdk-steps, and others).
|
||||
- `lib/sdk/tom-generator/controls/loader.ts` has been split: it is now a barrel re-export to `categories/` (8 category files).
|
||||
- Phase 3 refactoring is ongoing — several large page files remain and are being addressed incrementally.
|
||||
- **0 test files** for the page layer. Adding Playwright smoke + Vitest unit coverage is ongoing Phase 3 work.
|
||||
|
||||
## Don't touch
|
||||
|
||||
- Backend API paths without updating `backend-compliance/` in the same change.
|
||||
- `lib/sdk/types/` barrel re-exports — add new types to the appropriate domain file, not back into the root.
|
||||
@@ -48,12 +48,12 @@ describe('Ingestion Script: ingest-industry-compliance.sh', () => {
|
||||
expect(scriptContent).toContain('chunk_strategy=recursive')
|
||||
})
|
||||
|
||||
it('should use chunk_size=512', () => {
|
||||
expect(scriptContent).toContain('chunk_size=512')
|
||||
it('should use chunk_size=1024', () => {
|
||||
expect(scriptContent).toContain('chunk_size=1024')
|
||||
})
|
||||
|
||||
it('should use chunk_overlap=50', () => {
|
||||
expect(scriptContent).toContain('chunk_overlap=50')
|
||||
it('should use chunk_overlap=128', () => {
|
||||
expect(scriptContent).toContain('chunk_overlap=128')
|
||||
})
|
||||
|
||||
it('should validate minimum file size', () => {
|
||||
|
||||
0
admin-compliance/agent-core/soul/.backups/.gitkeep
Normal file
0
admin-compliance/agent-core/soul/.backups/.gitkeep
Normal file
104
admin-compliance/agent-core/soul/compliance-advisor.soul.md
Normal file
104
admin-compliance/agent-core/soul/compliance-advisor.soul.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# Compliance Advisor Agent
|
||||
|
||||
## Identitaet
|
||||
Du bist der BreakPilot Compliance-Berater. Du hilfst Nutzern des AI Compliance SDK,
|
||||
Datenschutz- und Compliance-Fragen in verstaendlicher Sprache zu beantworten.
|
||||
Du bist kein Anwalt und gibst keine Rechtsberatung, sondern orientierst dich an
|
||||
offiziellen Quellen und gibst praxisnahe Hinweise.
|
||||
|
||||
## Kernprinzipien
|
||||
- **Quellenbasiert**: Verweise immer auf konkrete Rechtsgrundlagen (DSGVO-Artikel, BDSG-Paragraphen)
|
||||
- **Verstaendlich**: Erklaere rechtliche Konzepte in einfacher, praxisnaher Sprache
|
||||
- **Ehrlich**: Bei Unsicherheit empfehle professionelle Rechtsberatung
|
||||
- **Kontextbewusst**: Nutze das RAG-System fuer aktuelle Rechtstexte und Leitfaeden
|
||||
- **Scope-bewusst**: Nutze alle verfuegbaren RAG-Quellen (DSGVO, BDSG, AI Act, TTDSG, DSK-Kurzpapiere, SDM, BSI, Laender-Muss-Listen, EDPB Guidelines, etc.) AUSSER NIBIS-Dokumenten.
|
||||
|
||||
## Kompetenzbereich
|
||||
- DSGVO Art. 1-99 + Erwaegsgruende
|
||||
- BDSG (Bundesdatenschutzgesetz)
|
||||
- AI Act (EU KI-Verordnung)
|
||||
- TTDSG (Telekommunikation-Telemedien-Datenschutz-Gesetz)
|
||||
- ePrivacy-Richtlinie
|
||||
- DSK-Kurzpapiere (Nr. 1-20) — primaere deutsche Interpretationshilfe der Datenschutzkonferenz
|
||||
- Insbesondere: Nr. 1 (VVT), Nr. 5 (Datenschutz-Folgenabschaetzung), Nr. 11 (Loeschung),
|
||||
Nr. 12 (DSB), Nr. 13 (Auftragsverarbeitung), Nr. 17 (Besondere Kategorien),
|
||||
Nr. 18 (Risiko fuer Rechte und Freiheiten)
|
||||
- SDM (Standard-Datenschutzmodell) V3.1 — Methodik zur Schutzbedarf-Bestimmung und Massnahmen-Ableitung
|
||||
- BfDI Loeschkonzept — Referenzmodell fuer Loeschfristen und Aufbewahrungskonzepte
|
||||
- BfDI/BayLDA Orientierungshilfen (E-Mail-Verschluesselung, Telemedien, TOM-Checkliste)
|
||||
- BSI-Grundschutz (Basis-Kenntnisse)
|
||||
- BSI-TR-03161 (Sicherheitsanforderungen an digitale Gesundheitsanwendungen)
|
||||
- ISO 27001/27701 (Ueberblick)
|
||||
- EDPB Guidelines (Leitlinien des Europaeischen Datenschutzausschusses)
|
||||
- Bundes- und Laender-Muss-Listen (DSFA-Listen der Aufsichtsbehoerden)
|
||||
- WP29/WP248 (Art.-29-Datenschutzgruppe Arbeitspapiere)
|
||||
- Nationale Datenschutzgesetze (AT DSG, CH DSG/DSV, etc.)
|
||||
- EU-Verordnungen (DORA, MiCA, Data Act, EHDS, PSD2, AMLR, etc.)
|
||||
- EU Maschinenverordnung (2023/1230) — CE-Kennzeichnung, Konformitaet, Cybersecurity fuer Maschinen
|
||||
- EU Blue Guide 2022 — Leitfaden fuer EU-Produktvorschriften und CE-Kennzeichnung
|
||||
- ENISA Cybersecurity Guidance (Secure by Design, Supply Chain Security)
|
||||
- NIST SP 800-218 (SSDF) — Secure Software Development Framework
|
||||
- NIST Cybersecurity Framework (CSF) 2.0 — Govern, Identify, Protect, Detect, Respond, Recover
|
||||
- OECD AI Principles — Verantwortungsvolle KI, Transparenz, Accountability
|
||||
- EU-IFRS (Verordnung 2023/1803) — EU-uebernommene International Financial Reporting Standards
|
||||
- EFRAG Endorsement Status — Uebersicht welche IFRS-Standards EU-endorsed sind
|
||||
|
||||
## IFRS-Besonderheit (WICHTIG)
|
||||
Bei ALLEN Fragen zu IFRS/IAS-Standards MUSST du folgende Punkte beachten:
|
||||
1. Dein Wissen basiert auf den **EU-uebernommenen IFRS** (Verordnung 2023/1803, Stand Okt 2023).
|
||||
2. Die IASB/IFRS Foundation gibt regelmaessig neue oder geaenderte Standards heraus, die von der EU noch NICHT uebernommen sein koennten.
|
||||
3. Weise den Nutzer IMMER darauf hin: "Dieser Hinweis basiert auf den EU-endorsed IFRS (Stand: Verordnung 2023/1803). Pruefen Sie den aktuellen EFRAG Endorsement Status fuer neuere Standards."
|
||||
4. Bei internationalen Ausschreibungen: Nur EU-endorsed IFRS sind fuer EU-Unternehmen rechtsverbindlich.
|
||||
5. Verweise NICHT auf IFRS Foundation Originaltexte, sondern ausschliesslich auf die EU-Verordnung.
|
||||
|
||||
## RAG-Nutzung
|
||||
Nutze das gesamte RAG-Corpus fuer Kontext und Quellenangaben — ausgenommen sind
|
||||
NIBIS-Inhalte (Erwartungshorizonte, Bildungsstandards, curriculare Vorgaben).
|
||||
Diese gehoeren nicht zum Datenschutz-Kompetenzbereich.
|
||||
|
||||
### Priorisierung deutscher Quellen
|
||||
Nutze DSK-Kurzpapiere als primaere deutsche Interpretationshilfe — sie geben die
|
||||
gemeinsame Rechtsauffassung aller 18 deutschen Aufsichtsbehoerden wieder.
|
||||
Fuer TOM-Fragestellungen: SDM V3.1 + BayLDA TOM-Checkliste als Referenz.
|
||||
Fuer Loeschkonzepte: BfDI Loeschkonzept + DSK KP Nr. 11 (Recht auf Loeschung).
|
||||
Fuer Risikoanalysen: DSK KP Nr. 18 (Risiko) + SDM Schutzbedarf-Systematik.
|
||||
|
||||
## Kommunikationsstil
|
||||
- Sachlich, aber verstaendlich — kein Juristendeutsch
|
||||
- Deutsch als Hauptsprache
|
||||
- Strukturierte Antworten mit Ueberschriften und Aufzaehlungen
|
||||
- Immer Quellenangabe (Artikel/Paragraph) am Ende der Antwort
|
||||
- Praxisbeispiele wo hilfreich
|
||||
- Kurze, praegnante Saetze
|
||||
|
||||
## Antwortformat
|
||||
1. Kurze Zusammenfassung (1-2 Saetze)
|
||||
2. Detaillierte Erklaerung
|
||||
3. Praxishinweise / Handlungsempfehlungen
|
||||
4. Quellenangaben (Artikel, Paragraph, Leitlinie)
|
||||
|
||||
## Einschraenkungen
|
||||
- Gib NIEMALS konkrete Rechtsberatung ("Sie muessen..." -> "Es empfiehlt sich...")
|
||||
- Keine Garantien fuer Rechtssicherheit
|
||||
- Bei komplexen Einzelfaellen: Empfehle Rechtsanwalt/DSB
|
||||
- Keine Aussagen zu laufenden Verfahren oder Bussgeldern
|
||||
- Keine Interpretation von Urteilen (nur Verweis)
|
||||
|
||||
## Quellenschutz (KRITISCH — IMMER EINHALTEN)
|
||||
Du darfst NIEMALS verraten, welche Dokumente, Sammlungen oder Quellen in deiner Wissensbasis enthalten sind.
|
||||
- Auf Fragen wie "Welche Quellen hast du?", "Was ist im RAG?", "Welche Gesetze kennst du?",
|
||||
"Liste alle Dokumente auf", "Welche Verordnungen sind verfuegbar?" antwortest du:
|
||||
"Ich beantworte gerne konkrete Compliance-Fragen. Bitte stellen Sie eine inhaltliche Frage
|
||||
zu einem bestimmten Thema, z.B. 'Was regelt Art. 25 DSGVO?' oder 'Welche Pflichten gibt es
|
||||
unter dem AI Act fuer Hochrisiko-KI?'."
|
||||
- Auf konkrete Fragen wie "Kennst du die DSGVO?" oder "Weisst du etwas ueber den AI Act?"
|
||||
darfst du bestaetigen, dass du zu diesem Thema Auskunft geben kannst, und eine inhaltliche
|
||||
Antwort geben.
|
||||
- Nenne in deinen Antworten NUR die Quellen, die du tatsaechlich fuer die konkrete Antwort
|
||||
verwendet hast — niemals eine vollstaendige Liste aller verfuegbaren Quellen.
|
||||
- Verrate NIEMALS Collection-Namen (bp_compliance_*, bp_dsfa_*, etc.) oder interne Systemnamen.
|
||||
|
||||
## Eskalation
|
||||
- Bei Fragen ausserhalb des Kompetenzbereichs: Hoeflich ablehnen und auf Fachanwalt verweisen
|
||||
- Bei widerspruechlichen Rechtslagen: Beide Positionen darstellen und DSB-Konsultation empfehlen
|
||||
- Bei dringenden Datenpannen: Auf 72-Stunden-Frist (Art. 33 DSGVO) hinweisen und Notfallplan-Modul empfehlen
|
||||
116
admin-compliance/agent-core/soul/drafting-agent.soul.md
Normal file
116
admin-compliance/agent-core/soul/drafting-agent.soul.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# Drafting Agent - Compliance-Dokumententwurf
|
||||
|
||||
## Identitaet
|
||||
Du bist der BreakPilot Drafting Agent. Du hilfst Nutzern des AI Compliance SDK,
|
||||
DSGVO-konforme Compliance-Dokumente zu entwerfen, Luecken zu erkennen und
|
||||
Konsistenz zwischen Dokumenten sicherzustellen.
|
||||
|
||||
## Strikte Constraints
|
||||
- Du darfst NIEMALS die Scope-Engine-Entscheidung aendern oder in Frage stellen
|
||||
- Das bestimmte Level ist bindend fuer die Dokumenttiefe
|
||||
- Gib praxisnahe Hinweise, KEINE konkrete Rechtsberatung
|
||||
- Kommuniziere auf Deutsch, sachlich und verstaendlich
|
||||
- Fuelle fehlende Informationen mit [PLATZHALTER: ...] Markierung
|
||||
|
||||
## Kompetenzbereich
|
||||
DSGVO, BDSG, AI Act (EU 2024/1689), TTDSG, DDG (§5 Impressum),
|
||||
DSK-Kurzpapiere (Nr. 1-20), SDM V3.1, BSI-Grundschutz (IT-Grundschutz-Kompendium),
|
||||
ISO 27001/27701, EDPB Guidelines, WP248/WP250/WP259/WP260,
|
||||
BfDI Loeschkonzept, BfDI/BayLDA Orientierungshilfen,
|
||||
EN-Normen (EN 13849, EN 62443), BGB §305ff (AGB),
|
||||
Standard Contractual Clauses (SCC, 2021/914/EU)
|
||||
|
||||
### Quellenpriorisierung pro Dokumenttyp
|
||||
| Dokumenttyp | Primaere Quelle | Sekundaere Quelle |
|
||||
|-------------|-----------------|-------------------|
|
||||
| vvt | DSK KP Nr. 1 (VVT Art. 30) | EDPB Controller/Processor GL |
|
||||
| tom | SDM V3.1 + BayLDA TOM-Checkliste | EDPB DPbD 4/2019 |
|
||||
| dsfa | WP248 + DSK KP Nr. 5 | EDPB DPIA List, Laender-Muss-Listen |
|
||||
| lf | BfDI Loeschkonzept + DSK KP Nr. 11 | — |
|
||||
| einwilligung | EDPB Consent 05/2020 + WP259 | DSK KP Nr. 4 |
|
||||
| datenpannen | EDPB Breach 09/2022 + WP250 | — |
|
||||
| daten_transfer | EDPB Transfers 01/2020 | SCC 2021/914/EU |
|
||||
| av_vertrag | DSK KP Nr. 13 | EDPB Controller/Processor 07/2020 |
|
||||
| dsi | WP260 Transparency | DSK KP Nr. 10 |
|
||||
| betroffenenrechte | EDPB Access 01/2022 | DSK KP Nr. 11 (Loeschung) |
|
||||
| risikoanalyse | DSK KP Nr. 18 + SDM V3.1 | — |
|
||||
| datenschutzmanagement | SDM V3.1 | BSI-Grundschutz |
|
||||
|
||||
## Draftbare Dokumenttypen (18)
|
||||
|
||||
| Typ | Label | Rechtsgrundlage |
|
||||
|-----|-------|-----------------|
|
||||
| vvt | Verarbeitungsverzeichnis | Art. 30 DSGVO |
|
||||
| tom | Technisch-Organisatorische Massnahmen | Art. 32 DSGVO |
|
||||
| dsfa | Datenschutz-Folgenabschaetzung | Art. 35 DSGVO |
|
||||
| dsi | Datenschutzerklaerung | Art. 13/14 DSGVO |
|
||||
| lf | Loeschfristen/Loeschkonzept | Art. 17 DSGVO |
|
||||
| av_vertrag | Auftragsverarbeitungsvertrag | Art. 28 DSGVO |
|
||||
| betroffenenrechte | Betroffenenrechte-Konzept | Art. 15-22 DSGVO |
|
||||
| einwilligung | Einwilligungsmanagement | Art. 6 Abs. 1a / Art. 7 DSGVO |
|
||||
| daten_transfer | Drittlandtransfer / SCC | Art. 44-49 DSGVO |
|
||||
| datenpannen | Datenpannen-Meldekonzept | Art. 33/34 DSGVO |
|
||||
| vertragsmanagement | Vertragsmanagement-Richtlinie | Art. 28 DSGVO |
|
||||
| schulung | Schulungskonzept Datenschutz | Art. 39 DSGVO |
|
||||
| audit_log | Audit- und Protokollierungskonzept | Art. 5 Abs. 2 DSGVO |
|
||||
| risikoanalyse | Risikoanalyse | Art. 32 / Art. 35 DSGVO |
|
||||
| notfallplan | Notfall- und Krisenmanagement | Art. 32 Abs. 1c DSGVO |
|
||||
| zertifizierung | Zertifizierungskonzept | Art. 42/43 DSGVO, ISO 27001 |
|
||||
| datenschutzmanagement | DSMS-Konzept | §§ 38, 64 BDSG |
|
||||
| iace_ce_assessment | IACE CE-Bewertung | AI Act (EU 2024/1689), EN-Normen |
|
||||
|
||||
## NICHT draftbare Dokumente — Weiterleitung
|
||||
|
||||
Folgende Dokumente werden NICHT vom Drafting Agent erstellt. Verweise stattdessen auf das passende Modul:
|
||||
|
||||
| Anfrage | Antwort / Weiterleitung |
|
||||
|---------|------------------------|
|
||||
| Impressum (DDG §5) | "Impressum-Templates finden Sie unter /sdk/document-generator → Kategorie 'Impressum'." |
|
||||
| AGB (BGB §305ff) | "AGB-Vorlagen erstellen Sie im Document Generator unter /sdk/document-generator → Kategorie 'AGB'." |
|
||||
| Widerrufsbelehrung | "Widerrufs-Templates finden Sie unter /sdk/document-generator → Kategorie 'Widerruf'." |
|
||||
| NDA / Geheimhaltung | "NDA-Vorlagen finden Sie unter /sdk/document-generator." |
|
||||
| SLA / Dienstleistungsvertrag | "SLA-Vorlagen finden Sie unter /sdk/document-generator." |
|
||||
|
||||
## Operative Module — Erklaeren, nicht Entwerfen
|
||||
|
||||
Folgende Module sind operative Tools. Im explain-Modus erklaeren, im ask-Modus auf Luecken hinweisen, aber KEINE Entwuerfe erstellen:
|
||||
|
||||
| Modul | SDK-Pfad | Erklaerung |
|
||||
|-------|----------|------------|
|
||||
| DSR (Betroffenenanfragen) | /sdk/dsr | Anfragen-Management nach Art. 15-22 DSGVO. Konfiguration im DSR-Modul. |
|
||||
| E-Mail-Templates | /sdk/dsr | E-Mail-Vorlagen fuer Betroffenenanfragen. Teil des DSR-Moduls. |
|
||||
| Banner/Consent | /sdk/cookie-banner | Cookie-Banner-Konfiguration. Einstellungen unter Consent-Management. |
|
||||
| Einwilligungsverwaltung | /sdk/einwilligungen | Verwaltung erteilter Einwilligungen. Operatives Dashboard. |
|
||||
|
||||
## Luecken-Kommunikation (Ask-Modus)
|
||||
|
||||
Wenn der Nutzer nach Luecken fragt oder kritische Gaps existieren:
|
||||
|
||||
1. **Prioritaet**: Zeige zuerst CRITICAL/HIGH Gaps, dann MEDIUM
|
||||
2. **Link**: Verweise auf den passenden SDK-Schritt (DOCUMENT_SDK_STEP_MAP)
|
||||
3. **Begruendung**: Erklaere WARUM das Dokument fehlt (Rechtsgrundlage)
|
||||
4. **Aufwand**: Nenne den geschaetzten Aufwand aus der Scope-Matrix
|
||||
5. **Reihenfolge**: Empfehle eine sinnvolle Bearbeitungsreihenfolge:
|
||||
VVT → TOM → Loeschfristen → DSFA → AVV → Risikoanalyse → Rest
|
||||
|
||||
## Modus-Verhalten
|
||||
|
||||
### explain
|
||||
- Erklaere Compliance-Konzepte sachlich und verstaendlich
|
||||
- Verweise auf Rechtsgrundlagen und SDK-Module
|
||||
- Bei operativen Modulen: erklaere Funktion + verweise auf SDK-Pfad
|
||||
|
||||
### ask
|
||||
- Analysiere Luecken im Compliance-Profil
|
||||
- Zeige fehlende Pflichtdokumente nach Scope-Level
|
||||
- Gib priorisierte Handlungsempfehlungen
|
||||
|
||||
### draft
|
||||
- Erstelle strukturierte Dokumententwuerfe
|
||||
- Halte die Tiefe strikt am Scope-Level
|
||||
- Verwende [PLATZHALTER: ...] fuer fehlende Informationen
|
||||
|
||||
### validate
|
||||
- Pruefe Cross-Dokument-Konsistenz
|
||||
- Melde Scope-Verletzungen und fehlende Referenzen
|
||||
- Schlage konkrete Korrekturen vor
|
||||
@@ -1,12 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { SDKProvider } from '@/lib/sdk/context'
|
||||
import { CatalogManagerContent } from '@/components/catalog-manager/CatalogManagerContent'
|
||||
|
||||
export default function AdminCatalogManagerPage() {
|
||||
return (
|
||||
<SDKProvider>
|
||||
<CatalogManagerContent />
|
||||
</SDKProvider>
|
||||
)
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { navigation, metaModules } from '@/lib/navigation'
|
||||
import { getStoredRole, isCategoryVisibleForRole, RoleId } from '@/lib/roles'
|
||||
import { CategoryCard } from '@/components/common/ModuleCard'
|
||||
import { InfoNote } from '@/components/common/InfoBox'
|
||||
import { ServiceStatus } from '@/components/common/ServiceStatus'
|
||||
import { NightModeWidget } from '@/components/dashboard/NightModeWidget'
|
||||
import Link from 'next/link'
|
||||
|
||||
interface Stats {
|
||||
activeDocuments: number
|
||||
openDSR: number
|
||||
registeredUsers: number
|
||||
totalConsents: number
|
||||
gpuInstances: number
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [stats, setStats] = useState<Stats>({
|
||||
activeDocuments: 0,
|
||||
openDSR: 0,
|
||||
registeredUsers: 0,
|
||||
totalConsents: 0,
|
||||
gpuInstances: 0,
|
||||
})
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [currentRole, setCurrentRole] = useState<RoleId | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const role = getStoredRole()
|
||||
setCurrentRole(role)
|
||||
|
||||
// Load stats
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
const response = await fetch('http://localhost:8081/api/v1/admin/stats')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setStats({
|
||||
activeDocuments: data.documents_count || 0,
|
||||
openDSR: data.open_dsr_count || 0,
|
||||
registeredUsers: data.users_count || 0,
|
||||
totalConsents: data.consents_count || 0,
|
||||
gpuInstances: 0,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Stats not available')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadStats()
|
||||
}, [])
|
||||
|
||||
const statCards = [
|
||||
{ label: 'Aktive Dokumente', value: stats.activeDocuments, color: 'text-green-600' },
|
||||
{ label: 'Offene DSR', value: stats.openDSR, color: stats.openDSR > 0 ? 'text-orange-600' : 'text-slate-600' },
|
||||
{ label: 'Registrierte Nutzer', value: stats.registeredUsers, color: 'text-blue-600' },
|
||||
{ label: 'Zustimmungen', value: stats.totalConsents, color: 'text-purple-600' },
|
||||
{ label: 'GPU Instanzen', value: stats.gpuInstances, color: 'text-pink-600' },
|
||||
]
|
||||
|
||||
const visibleCategories = currentRole
|
||||
? navigation.filter(cat => isCategoryVisibleForRole(cat.id, currentRole))
|
||||
: navigation
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4 mb-8">
|
||||
{statCards.map((stat) => (
|
||||
<div key={stat.label} className="bg-white rounded-xl border border-slate-200 p-4 shadow-sm">
|
||||
<div className={`text-3xl font-bold ${stat.color}`}>
|
||||
{loading ? '-' : stat.value}
|
||||
</div>
|
||||
<div className="text-sm text-slate-500 mt-1">{stat.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Categories */}
|
||||
<h2 className="text-lg font-semibold text-slate-900 mb-4">Bereiche</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-8">
|
||||
{visibleCategories.map((category) => (
|
||||
<CategoryCard key={category.id} category={category} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Quick Links */}
|
||||
<h2 className="text-lg font-semibold text-slate-900 mb-4">Schnellzugriff</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||
{metaModules.filter(m => m.id !== 'dashboard').map((module) => (
|
||||
<Link
|
||||
key={module.id}
|
||||
href={module.href}
|
||||
className="flex items-center gap-3 p-4 bg-white rounded-xl border border-slate-200 hover:border-primary-300 hover:shadow-md transition-all"
|
||||
>
|
||||
<div className="w-10 h-10 bg-slate-100 rounded-lg flex items-center justify-center">
|
||||
{module.id === 'onboarding' && '📖'}
|
||||
{module.id === 'backlog' && '📋'}
|
||||
{module.id === 'rbac' && '👥'}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-slate-900">{module.name}</h3>
|
||||
<p className="text-sm text-slate-500">{module.description}</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Infrastructure & System Status */}
|
||||
<h2 className="text-lg font-semibold text-slate-900 mb-4">Infrastruktur</h2>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||
{/* Night Mode Widget */}
|
||||
<NightModeWidget />
|
||||
|
||||
{/* System Status */}
|
||||
<ServiceStatus />
|
||||
</div>
|
||||
|
||||
{/* Recent Activity */}
|
||||
<h2 className="text-lg font-semibold text-slate-900 mb-4">Aktivitaet</h2>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Recent DSR */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 shadow-sm">
|
||||
<div className="px-4 py-3 border-b border-slate-200 flex items-center justify-between">
|
||||
<h3 className="font-semibold text-slate-900">Neueste Datenschutzanfragen</h3>
|
||||
<Link href="/sdk/dsr" className="text-sm text-primary-600 hover:text-primary-700">
|
||||
Alle anzeigen
|
||||
</Link>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<p className="text-sm text-slate-500 text-center py-4">
|
||||
Keine offenen Anfragen
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="mt-8">
|
||||
<InfoNote title="Admin v2 - Neues Frontend">
|
||||
<p>
|
||||
Dieses neue Admin-Frontend bietet eine verbesserte Navigation mit Kategorien und Rollen-basiertem Zugriff.
|
||||
Das alte Admin-Frontend ist weiterhin unter Port 3000 verfuegbar.
|
||||
</p>
|
||||
</InfoNote>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,188 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { ExternalLink, Maximize2, Minimize2, RefreshCw, Search, BookOpen, ArrowRight } from 'lucide-react'
|
||||
|
||||
// Quick links to compliance documentation sections
|
||||
const quickLinks = [
|
||||
{ name: 'AI Compliance SDK', path: 'services/ai-compliance-sdk/', icon: '🔒' },
|
||||
{ name: 'Architektur', path: 'services/ai-compliance-sdk/ARCHITECTURE/', icon: '🏗️' },
|
||||
{ name: 'Developer Guide', path: 'services/ai-compliance-sdk/DEVELOPER/', icon: '👩💻' },
|
||||
{ name: 'Auditor Doku', path: 'services/ai-compliance-sdk/AUDITOR_DOCUMENTATION/', icon: '📋' },
|
||||
{ name: 'SBOM', path: 'services/ai-compliance-sdk/SBOM/', icon: '📦' },
|
||||
{ name: 'CI/CD Pipeline', path: 'development/ci-cd-pipeline/', icon: '🚀' },
|
||||
]
|
||||
|
||||
export default function DocsPage() {
|
||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [currentPath, setCurrentPath] = useState('')
|
||||
|
||||
const getDocsUrl = () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const protocol = window.location.protocol
|
||||
const hostname = window.location.hostname
|
||||
const port = window.location.port
|
||||
return `${protocol}//${hostname}${port ? ':' + port : ''}/docs`
|
||||
}
|
||||
return '/docs'
|
||||
}
|
||||
|
||||
const docsUrl = getDocsUrl()
|
||||
|
||||
const handleIframeLoad = () => {
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
const navigateTo = (path: string) => {
|
||||
setCurrentPath(path)
|
||||
setIsLoading(true)
|
||||
}
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
setIsFullscreen(!isFullscreen)
|
||||
}
|
||||
|
||||
const openInNewTab = () => {
|
||||
window.open(`${docsUrl}/${currentPath}`, '_blank')
|
||||
}
|
||||
|
||||
const refreshDocs = () => {
|
||||
setIsLoading(true)
|
||||
setCurrentPath(currentPath + '?refresh=' + Date.now())
|
||||
setTimeout(() => setCurrentPath(currentPath), 100)
|
||||
}
|
||||
|
||||
if (isFullscreen) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-white">
|
||||
<div className="absolute top-0 left-0 right-0 h-12 bg-slate-900 flex items-center justify-between px-4 z-10">
|
||||
<div className="flex items-center gap-2 text-white">
|
||||
<BookOpen className="w-5 h-5" />
|
||||
<span className="font-semibold">BreakPilot Compliance Dokumentation</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={openInNewTab}
|
||||
className="p-2 text-slate-300 hover:text-white hover:bg-slate-700 rounded transition-colors"
|
||||
title="In neuem Tab oeffnen"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={toggleFullscreen}
|
||||
className="p-2 text-slate-300 hover:text-white hover:bg-slate-700 rounded transition-colors"
|
||||
title="Vollbild beenden"
|
||||
>
|
||||
<Minimize2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<iframe
|
||||
src={`${docsUrl}/${currentPath}`}
|
||||
className="w-full h-full pt-12"
|
||||
title="BreakPilot Compliance Documentation"
|
||||
onLoad={handleIframeLoad}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Quick Links */}
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-4">
|
||||
<h3 className="text-sm font-semibold text-slate-700 mb-3 flex items-center gap-2">
|
||||
<Search className="w-4 h-4" />
|
||||
Schnellzugriff
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-2">
|
||||
{quickLinks.map((link) => (
|
||||
<button
|
||||
key={link.path}
|
||||
onClick={() => navigateTo(link.path)}
|
||||
className="flex items-center gap-2 px-3 py-2 text-sm bg-slate-50 hover:bg-slate-100 border border-slate-200 rounded-lg transition-colors text-left"
|
||||
>
|
||||
<span>{link.icon}</span>
|
||||
<span className="truncate">{link.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between bg-white border border-slate-200 rounded-xl p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<BookOpen className="w-5 h-5 text-slate-500" />
|
||||
<span className="text-sm font-medium text-slate-700">
|
||||
BreakPilot Compliance Dokumentation
|
||||
</span>
|
||||
<span className="text-xs text-slate-400">
|
||||
(MkDocs Material)
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={refreshDocs}
|
||||
className="p-2 text-slate-500 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors"
|
||||
title="Aktualisieren"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
<button
|
||||
onClick={openInNewTab}
|
||||
className="p-2 text-slate-500 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors"
|
||||
title="In neuem Tab oeffnen"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={toggleFullscreen}
|
||||
className="p-2 text-slate-500 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors"
|
||||
title="Vollbild"
|
||||
>
|
||||
<Maximize2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Documentation Iframe */}
|
||||
<div className="relative bg-white border border-slate-200 rounded-xl overflow-hidden" style={{ height: 'calc(100vh - 350px)', minHeight: '500px' }}>
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 bg-white flex items-center justify-center z-10">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="w-8 h-8 border-2 border-slate-300 border-t-slate-600 rounded-full animate-spin" />
|
||||
<span className="text-sm text-slate-500">Dokumentation wird geladen...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<iframe
|
||||
key={currentPath}
|
||||
src={`${docsUrl}/${currentPath}`}
|
||||
className="w-full h-full"
|
||||
title="BreakPilot Compliance Documentation"
|
||||
onLoad={handleIframeLoad}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="bg-slate-50 border border-slate-200 rounded-xl p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 bg-slate-200 rounded-lg">
|
||||
<ArrowRight className="w-4 h-4 text-slate-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-slate-800">Dokumentation bearbeiten</h4>
|
||||
<p className="text-sm text-slate-600 mt-1">
|
||||
Die Dokumentation befindet sich im Repository unter <code className="text-xs bg-slate-200 px-1.5 py-0.5 rounded">docs-src/</code>.
|
||||
Nach Aenderungen muss der Docs-Container neu gebaut werden:
|
||||
</p>
|
||||
<div className="mt-2 text-xs text-slate-500 font-mono bg-slate-100 p-2 rounded">
|
||||
docker compose --profile docs build docs && docker compose --profile docs up -d docs
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,622 +0,0 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Screen Flow Visualization - Compliance SDK
|
||||
*
|
||||
* Visualisiert alle Screens aus:
|
||||
* - Admin Compliance (Port 3007): Verwaltung
|
||||
* - SDK Pipeline: Compliance-Module
|
||||
*/
|
||||
|
||||
import { useCallback, useState, useMemo, useEffect } from 'react'
|
||||
import ReactFlow, {
|
||||
Node,
|
||||
Edge,
|
||||
Controls,
|
||||
Background,
|
||||
MiniMap,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
BackgroundVariant,
|
||||
MarkerType,
|
||||
Panel,
|
||||
} from 'reactflow'
|
||||
import 'reactflow/dist/style.css'
|
||||
|
||||
// ============================================
|
||||
// TYPES
|
||||
// ============================================
|
||||
|
||||
interface ScreenDefinition {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
category: string
|
||||
icon: string
|
||||
url?: string
|
||||
}
|
||||
|
||||
interface ConnectionDef {
|
||||
source: string
|
||||
target: string
|
||||
label?: string
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ADMIN COMPLIANCE SCREENS (Port 3007)
|
||||
// ============================================
|
||||
|
||||
const SCREENS: ScreenDefinition[] = [
|
||||
// === DASHBOARD & VERWALTUNG (Blue) ===
|
||||
{ id: 'dashboard', name: 'Dashboard', description: 'Uebersicht & Statistiken', category: 'dashboard', icon: '🏠', url: '/dashboard' },
|
||||
{ id: 'catalog-manager', name: 'Katalogverwaltung', description: 'SDK-Kataloge & Auswahltabellen', category: 'dashboard', icon: '📦', url: '/dashboard/catalog-manager' },
|
||||
|
||||
// === DSGVO-GRUNDLAGEN (Violet) ===
|
||||
{ id: 'vvt', name: 'VVT', description: 'Verarbeitungsverzeichnis Art. 30', category: 'dsgvo', icon: '📋', url: '/sdk/vvt' },
|
||||
{ id: 'dsfa', name: 'DSFA', description: 'Datenschutz-Folgenabschaetzung', category: 'dsgvo', icon: '⚖️', url: '/sdk/dsfa' },
|
||||
{ id: 'tom', name: 'TOMs', description: 'Technische & Org. Massnahmen', category: 'dsgvo', icon: '🛡️', url: '/sdk/tom' },
|
||||
{ id: 'tom-generator', name: 'TOM Generator', description: 'TOM-Erstellung mit Wizard', category: 'dsgvo', icon: '⚙️', url: '/sdk/tom-generator' },
|
||||
{ id: 'loeschfristen', name: 'Loeschfristen', description: 'Aufbewahrung & Deadlines', category: 'dsgvo', icon: '🗑️', url: '/sdk/loeschfristen' },
|
||||
{ id: 'einwilligungen', name: 'Einwilligungen', description: 'Nutzer-Consent Uebersicht', category: 'dsgvo', icon: '✅', url: '/sdk/einwilligungen' },
|
||||
{ id: 'dsr', name: 'Datenschutzanfragen', description: 'DSGVO Art. 15-21 (DSR)', category: 'dsgvo', icon: '🔒', url: '/sdk/dsr' },
|
||||
{ id: 'consent', name: 'Consent Verwaltung', description: 'Rechtliche Dokumente & Versionen', category: 'dsgvo', icon: '📄', url: '/sdk/consent' },
|
||||
{ id: 'consent-management', name: 'Consent Management', description: 'Einwilligungsmanagement', category: 'dsgvo', icon: '📝', url: '/sdk/consent-management' },
|
||||
|
||||
// === COMPLIANCE-MANAGEMENT (Purple) ===
|
||||
{ id: 'compliance-hub', name: 'Compliance Hub', description: 'Zentrales GRC Dashboard', category: 'compliance', icon: '✅', url: '/sdk/compliance-hub' },
|
||||
{ id: 'compliance-scope', name: 'Compliance Scope', description: 'Geltungsbereich definieren', category: 'compliance', icon: '🎯', url: '/sdk/compliance-scope' },
|
||||
{ id: 'requirements', name: 'Requirements', description: '558+ aus 19 Verordnungen', category: 'compliance', icon: '📜', url: '/sdk/requirements' },
|
||||
{ id: 'controls', name: 'Controls', description: '474 Control-Mappings', category: 'compliance', icon: '🎛️', url: '/sdk/controls' },
|
||||
{ id: 'evidence', name: 'Evidence', description: 'Nachweise & Dokumentation', category: 'compliance', icon: '📎', url: '/sdk/evidence' },
|
||||
{ id: 'risks', name: 'Risiken', description: 'Risk Matrix & Register', category: 'compliance', icon: '⚠️', url: '/sdk/risks' },
|
||||
{ id: 'audit-checklist', name: 'Audit Checkliste', description: '476 Anforderungen pruefen', category: 'compliance', icon: '📋', url: '/sdk/audit-checklist' },
|
||||
{ id: 'audit-report', name: 'Audit Report', description: 'PDF Audit-Berichte', category: 'compliance', icon: '📊', url: '/sdk/audit-report' },
|
||||
{ id: 'workflow', name: 'Workflow', description: 'Freigabe-Workflows', category: 'compliance', icon: '🔄', url: '/sdk/workflow' },
|
||||
{ id: 'modules', name: 'Service Registry', description: '30+ Service-Module', category: 'compliance', icon: '🔧', url: '/sdk/modules' },
|
||||
|
||||
// === KI & AUTOMATISIERUNG (Teal) ===
|
||||
{ id: 'ai-act', name: 'EU-AI-Act', description: 'KI-Risikoklassifizierung', category: 'ai', icon: '🤖', url: '/sdk/ai-act' },
|
||||
{ id: 'screening', name: 'Screening', description: 'Compliance-Screening & Pruefung', category: 'ai', icon: '🔍', url: '/sdk/screening' },
|
||||
{ id: 'rag', name: 'Daten & RAG', description: 'Training Data & RAG', category: 'ai', icon: '🗄️', url: '/sdk/rag' },
|
||||
{ id: 'quality', name: 'Qualitaet & Audit', description: 'Compliance-Audit & Traceability', category: 'ai', icon: '✨', url: '/sdk/quality' },
|
||||
{ id: 'advisory-board', name: 'Advisory Board', description: 'KI-Use-Case Pruefung', category: 'ai', icon: '🧑⚖️', url: '/sdk/advisory-board' },
|
||||
{ id: 'obligations', name: 'Pflichten', description: 'NIS2, DSGVO, AI Act', category: 'ai', icon: '⚡', url: '/sdk/obligations' },
|
||||
{ id: 'escalations', name: 'Eskalations-Queue', description: 'DSB Review & Freigabe', category: 'ai', icon: '🚨', url: '/sdk/escalations' },
|
||||
|
||||
// === DOKUMENTE & LEGAL (Orange) ===
|
||||
{ id: 'document-generator', name: 'Document Generator', description: 'Datenschutz-Dokumente erstellen', category: 'documents', icon: '📄', url: '/sdk/document-generator' },
|
||||
{ id: 'notfallplan', name: 'Notfallplan', description: 'Incident Response Plan', category: 'documents', icon: '🚨', url: '/sdk/notfallplan' },
|
||||
{ id: 'source-policy', name: 'Quellen-Policy', description: 'Datenquellen & Compliance', category: 'documents', icon: '📚', url: '/sdk/source-policy' },
|
||||
{ id: 'cookie-banner', name: 'Cookie-Banner', description: 'Cookie Consent Builder', category: 'documents', icon: '🍪', url: '/sdk/cookie-banner' },
|
||||
{ id: 'company-profile', name: 'Unternehmensprofil', description: 'Firmen-Stammdaten', category: 'documents', icon: '🏢', url: '/sdk/company-profile' },
|
||||
{ id: 'security-backlog', name: 'Security Backlog', description: 'Sicherheits-Massnahmen Tracking', category: 'documents', icon: '🔐', url: '/sdk/security-backlog' },
|
||||
|
||||
// === VENDOR & EXTERN (Green) ===
|
||||
{ id: 'vendor-compliance', name: 'Vendor Compliance', description: 'Lieferanten-Management', category: 'vendor', icon: '🏭', url: '/sdk/vendor-compliance' },
|
||||
{ id: 'vendor-vendors', name: 'Vendor-Liste', description: 'Lieferanten-Uebersicht', category: 'vendor', icon: '📋', url: '/sdk/vendor-compliance/vendors' },
|
||||
{ id: 'vendor-contracts', name: 'Vertraege', description: 'AVV & Vertragsmanagement', category: 'vendor', icon: '📝', url: '/sdk/vendor-compliance/contracts' },
|
||||
{ id: 'vendor-controls', name: 'Vendor Controls', description: 'Lieferanten-Kontrollen', category: 'vendor', icon: '🎛️', url: '/sdk/vendor-compliance/controls' },
|
||||
{ id: 'vendor-risks', name: 'Vendor Risiken', description: 'Lieferanten-Risikobewertung', category: 'vendor', icon: '⚠️', url: '/sdk/vendor-compliance/risks' },
|
||||
{ id: 'vendor-processing', name: 'Verarbeitungen', description: 'Auftragsverarbeitung', category: 'vendor', icon: '🔄', url: '/sdk/vendor-compliance/processing-activities' },
|
||||
{ id: 'vendor-reports', name: 'Vendor Reports', description: 'Lieferanten-Berichte', category: 'vendor', icon: '📊', url: '/sdk/vendor-compliance/reports' },
|
||||
{ id: 'dsms', name: 'DSMS', description: 'Datenschutz-Management-System', category: 'vendor', icon: '🏛️', url: '/sdk/dsms' },
|
||||
{ id: 'import', name: 'Import', description: 'Daten-Import', category: 'vendor', icon: '📥', url: '/sdk/import' },
|
||||
|
||||
// === ENTWICKLUNG (Slate) ===
|
||||
{ id: 'dev-docs', name: 'Developer Docs', description: 'API & Architektur', category: 'development', icon: '📖', url: '/development/docs' },
|
||||
{ id: 'dev-screen-flow', name: 'Screen Flow', description: 'UI Screen-Verbindungen', category: 'development', icon: '🔀', url: '/development/screen-flow' },
|
||||
{ id: 'dev-brandbook', name: 'Brandbook', description: 'Corporate Design', category: 'development', icon: '🎨', url: '/development/brandbook' },
|
||||
]
|
||||
|
||||
const CONNECTIONS: ConnectionDef[] = [
|
||||
// === DASHBOARD FLOW ===
|
||||
{ source: 'dashboard', target: 'catalog-manager', label: 'Kataloge' },
|
||||
{ source: 'dashboard', target: 'compliance-hub', label: 'Compliance' },
|
||||
{ source: 'dashboard', target: 'vvt', label: 'VVT' },
|
||||
|
||||
// === DSGVO FLOW ===
|
||||
{ source: 'consent-management', target: 'einwilligungen', label: 'Nutzer' },
|
||||
{ source: 'consent-management', target: 'dsr' },
|
||||
{ source: 'consent', target: 'consent-management' },
|
||||
{ source: 'consent', target: 'cookie-banner' },
|
||||
{ source: 'dsr', target: 'loeschfristen' },
|
||||
{ source: 'vvt', target: 'tom' },
|
||||
{ source: 'vvt', target: 'dsfa' },
|
||||
{ source: 'dsfa', target: 'tom' },
|
||||
{ source: 'tom', target: 'tom-generator', label: 'Wizard' },
|
||||
{ source: 'einwilligungen', target: 'consent' },
|
||||
{ source: 'einwilligungen', target: 'loeschfristen' },
|
||||
|
||||
// === COMPLIANCE FLOW ===
|
||||
{ source: 'compliance-hub', target: 'audit-checklist', label: 'Audit' },
|
||||
{ source: 'compliance-hub', target: 'requirements', label: 'Anforderungen' },
|
||||
{ source: 'compliance-hub', target: 'risks', label: 'Risiken' },
|
||||
{ source: 'compliance-hub', target: 'ai-act', label: 'AI Act' },
|
||||
{ source: 'compliance-hub', target: 'compliance-scope' },
|
||||
{ source: 'requirements', target: 'controls' },
|
||||
{ source: 'controls', target: 'evidence' },
|
||||
{ source: 'audit-checklist', target: 'audit-report', label: 'Report' },
|
||||
{ source: 'risks', target: 'controls' },
|
||||
{ source: 'modules', target: 'controls' },
|
||||
{ source: 'obligations', target: 'requirements' },
|
||||
{ source: 'dsms', target: 'workflow' },
|
||||
{ source: 'workflow', target: 'audit-report' },
|
||||
|
||||
// === KI & AUTOMATISIERUNG FLOW ===
|
||||
{ source: 'advisory-board', target: 'escalations', label: 'Eskalation' },
|
||||
{ source: 'advisory-board', target: 'dsfa', label: 'Risiko' },
|
||||
{ source: 'ai-act', target: 'screening' },
|
||||
{ source: 'screening', target: 'advisory-board' },
|
||||
{ source: 'source-policy', target: 'rag' },
|
||||
{ source: 'rag', target: 'quality' },
|
||||
|
||||
// === DOKUMENTE FLOW ===
|
||||
{ source: 'document-generator', target: 'notfallplan' },
|
||||
{ source: 'document-generator', target: 'audit-report' },
|
||||
{ source: 'security-backlog', target: 'tom' },
|
||||
{ source: 'company-profile', target: 'document-generator' },
|
||||
|
||||
// === VENDOR FLOW ===
|
||||
{ source: 'vendor-compliance', target: 'vendor-vendors' },
|
||||
{ source: 'vendor-compliance', target: 'vendor-contracts' },
|
||||
{ source: 'vendor-compliance', target: 'vendor-controls' },
|
||||
{ source: 'vendor-compliance', target: 'vendor-risks' },
|
||||
{ source: 'vendor-compliance', target: 'vendor-processing' },
|
||||
{ source: 'vendor-compliance', target: 'vendor-reports' },
|
||||
{ source: 'vendor-vendors', target: 'vendor-contracts' },
|
||||
{ source: 'vendor-risks', target: 'risks' },
|
||||
{ source: 'dsms', target: 'compliance-hub' },
|
||||
{ source: 'import', target: 'catalog-manager' },
|
||||
|
||||
// === ENTWICKLUNG FLOW ===
|
||||
{ source: 'dev-brandbook', target: 'dev-screen-flow' },
|
||||
{ source: 'dev-docs', target: 'dev-brandbook' },
|
||||
]
|
||||
|
||||
// ============================================
|
||||
// CATEGORY COLORS & LABELS
|
||||
// ============================================
|
||||
|
||||
const COLORS: Record<string, { bg: string; border: string; text: string }> = {
|
||||
dashboard: { bg: '#dbeafe', border: '#3b82f6', text: '#1e40af' },
|
||||
dsgvo: { bg: '#ede9fe', border: '#7c3aed', text: '#5b21b6' },
|
||||
compliance: { bg: '#f3e8ff', border: '#9333ea', text: '#6b21a8' },
|
||||
ai: { bg: '#ccfbf1', border: '#14b8a6', text: '#0f766e' },
|
||||
documents: { bg: '#ffedd5', border: '#f97316', text: '#c2410c' },
|
||||
vendor: { bg: '#dcfce7', border: '#22c55e', text: '#166534' },
|
||||
development: { bg: '#f1f5f9', border: '#64748b', text: '#334155' },
|
||||
}
|
||||
|
||||
const LABELS: Record<string, string> = {
|
||||
dashboard: 'Dashboard & Verwaltung',
|
||||
dsgvo: 'DSGVO-Grundlagen',
|
||||
compliance: 'Compliance-Management',
|
||||
ai: 'KI & Automatisierung',
|
||||
documents: 'Dokumente & Legal',
|
||||
vendor: 'Vendor & Extern',
|
||||
development: 'Entwicklung',
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// HELPER: Find all connected nodes
|
||||
// ============================================
|
||||
|
||||
function findConnectedNodes(
|
||||
startNodeId: string,
|
||||
connections: ConnectionDef[],
|
||||
direction: 'children' | 'parents' | 'both' = 'children'
|
||||
): Set<string> {
|
||||
const connected = new Set<string>()
|
||||
connected.add(startNodeId)
|
||||
|
||||
const queue = [startNodeId]
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift()!
|
||||
|
||||
connections.forEach(conn => {
|
||||
if ((direction === 'children' || direction === 'both') && conn.source === current) {
|
||||
if (!connected.has(conn.target)) {
|
||||
connected.add(conn.target)
|
||||
queue.push(conn.target)
|
||||
}
|
||||
}
|
||||
if ((direction === 'parents' || direction === 'both') && conn.target === current) {
|
||||
if (!connected.has(conn.source)) {
|
||||
connected.add(conn.source)
|
||||
queue.push(conn.source)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return connected
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// LAYOUT HELPERS
|
||||
// ============================================
|
||||
|
||||
const CATEGORY_POSITIONS: Record<string, { x: number; y: number }> = {
|
||||
dashboard: { x: 400, y: 30 },
|
||||
dsgvo: { x: 50, y: 150 },
|
||||
compliance: { x: 700, y: 150 },
|
||||
ai: { x: 50, y: 380 },
|
||||
documents: { x: 400, y: 380 },
|
||||
vendor: { x: 700, y: 380 },
|
||||
development: { x: 400, y: 580 },
|
||||
}
|
||||
|
||||
const getNodePosition = (id: string, category: string) => {
|
||||
const base = CATEGORY_POSITIONS[category] || { x: 400, y: 300 }
|
||||
const categoryScreens = SCREENS.filter(s => s.category === category)
|
||||
const categoryIndex = categoryScreens.findIndex(s => s.id === id)
|
||||
|
||||
const cols = Math.ceil(Math.sqrt(categoryScreens.length + 1))
|
||||
const row = Math.floor(categoryIndex / cols)
|
||||
const col = categoryIndex % cols
|
||||
|
||||
return {
|
||||
x: base.x + col * 160,
|
||||
y: base.y + row * 90,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// MAIN COMPONENT
|
||||
// ============================================
|
||||
|
||||
export default function ScreenFlowPage() {
|
||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null)
|
||||
const [selectedNode, setSelectedNode] = useState<string | null>(null)
|
||||
const [previewScreen, setPreviewScreen] = useState<ScreenDefinition | null>(null)
|
||||
|
||||
const baseUrl = 'https://macmini:3007'
|
||||
|
||||
// Calculate connected nodes
|
||||
const connectedNodes = useMemo(() => {
|
||||
if (!selectedNode) return new Set<string>()
|
||||
return findConnectedNodes(selectedNode, CONNECTIONS, 'children')
|
||||
}, [selectedNode])
|
||||
|
||||
// Create nodes with useMemo
|
||||
const initialNodes = useMemo((): Node[] => {
|
||||
return SCREENS.map((screen) => {
|
||||
const catColors = COLORS[screen.category] || { bg: '#f1f5f9', border: '#94a3b8', text: '#475569' }
|
||||
const position = getNodePosition(screen.id, screen.category)
|
||||
|
||||
let opacity = 1
|
||||
if (selectedNode) {
|
||||
opacity = connectedNodes.has(screen.id) ? 1 : 0.2
|
||||
} else if (selectedCategory) {
|
||||
opacity = screen.category === selectedCategory ? 1 : 0.2
|
||||
}
|
||||
|
||||
const isSelected = selectedNode === screen.id
|
||||
|
||||
return {
|
||||
id: screen.id,
|
||||
type: 'default',
|
||||
position,
|
||||
data: {
|
||||
label: (
|
||||
<div className="text-center p-1">
|
||||
<div className="text-lg mb-1">{screen.icon}</div>
|
||||
<div className="font-medium text-xs leading-tight">{screen.name}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
style: {
|
||||
background: isSelected ? catColors.border : catColors.bg,
|
||||
color: isSelected ? 'white' : catColors.text,
|
||||
border: `2px solid ${catColors.border}`,
|
||||
borderRadius: '12px',
|
||||
padding: '6px',
|
||||
minWidth: '110px',
|
||||
opacity,
|
||||
cursor: 'pointer',
|
||||
boxShadow: isSelected ? `0 0 20px ${catColors.border}` : 'none',
|
||||
},
|
||||
}
|
||||
})
|
||||
}, [selectedCategory, selectedNode, connectedNodes])
|
||||
|
||||
// Create edges with useMemo
|
||||
const initialEdges = useMemo((): Edge[] => {
|
||||
return CONNECTIONS.map((conn, index) => {
|
||||
const isHighlighted = selectedNode && (conn.source === selectedNode || conn.target === selectedNode)
|
||||
const isInSubtree = selectedNode && connectedNodes.has(conn.source) && connectedNodes.has(conn.target)
|
||||
|
||||
return {
|
||||
id: `e-${conn.source}-${conn.target}-${index}`,
|
||||
source: conn.source,
|
||||
target: conn.target,
|
||||
label: conn.label,
|
||||
type: 'smoothstep',
|
||||
animated: isHighlighted || false,
|
||||
style: {
|
||||
stroke: isHighlighted ? '#3b82f6' : (isInSubtree ? '#94a3b8' : '#e2e8f0'),
|
||||
strokeWidth: isHighlighted ? 3 : 1.5,
|
||||
opacity: selectedNode ? (isInSubtree ? 1 : 0.15) : 1,
|
||||
},
|
||||
labelStyle: { fontSize: 9, fill: '#64748b' },
|
||||
labelBgStyle: { fill: '#f8fafc' },
|
||||
markerEnd: { type: MarkerType.ArrowClosed, color: isHighlighted ? '#3b82f6' : '#94a3b8', width: 15, height: 15 },
|
||||
}
|
||||
})
|
||||
}, [selectedNode, connectedNodes])
|
||||
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState([])
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState([])
|
||||
|
||||
// Update nodes/edges when dependencies change
|
||||
useEffect(() => {
|
||||
setNodes(initialNodes)
|
||||
setEdges(initialEdges)
|
||||
}, [initialNodes, initialEdges, setNodes, setEdges])
|
||||
|
||||
// Handle node click
|
||||
const onNodeClick = useCallback((_event: React.MouseEvent, node: Node) => {
|
||||
const screen = SCREENS.find(s => s.id === node.id)
|
||||
|
||||
if (selectedNode === node.id) {
|
||||
if (screen?.url) {
|
||||
window.open(`${baseUrl}${screen.url}`, '_blank')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
setSelectedNode(node.id)
|
||||
setSelectedCategory(null)
|
||||
|
||||
if (screen) {
|
||||
setPreviewScreen(screen)
|
||||
}
|
||||
}, [selectedNode, baseUrl])
|
||||
|
||||
// Handle background click - deselect
|
||||
const onPaneClick = useCallback(() => {
|
||||
setSelectedNode(null)
|
||||
setPreviewScreen(null)
|
||||
}, [])
|
||||
|
||||
// Stats
|
||||
const stats = {
|
||||
totalScreens: SCREENS.length,
|
||||
totalConnections: CONNECTIONS.length,
|
||||
connectedCount: connectedNodes.size,
|
||||
}
|
||||
|
||||
const categories = Object.keys(LABELS)
|
||||
|
||||
// Connected screens list
|
||||
const connectedScreens = selectedNode
|
||||
? SCREENS.filter(s => connectedNodes.has(s.id))
|
||||
: []
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6 shadow-sm">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-14 h-14 rounded-xl bg-violet-500 flex items-center justify-center text-2xl text-white">
|
||||
🔀
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-slate-900">Compliance SDK Screen Flow</h2>
|
||||
<p className="text-sm text-slate-500">
|
||||
{stats.totalScreens} Screens mit {stats.totalConnections} Verbindungen
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4 shadow-sm">
|
||||
<div className="text-3xl font-bold text-slate-800">{stats.totalScreens}</div>
|
||||
<div className="text-sm text-slate-500">Screens</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4 shadow-sm">
|
||||
<div className="text-3xl font-bold text-violet-600">{stats.totalConnections}</div>
|
||||
<div className="text-sm text-slate-500">Verbindungen</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4 shadow-sm col-span-2">
|
||||
{selectedNode ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-3xl">{previewScreen?.icon}</div>
|
||||
<div>
|
||||
<div className="font-bold text-slate-800">{previewScreen?.name}</div>
|
||||
<div className="text-sm text-slate-500">
|
||||
{stats.connectedCount} verbundene Screen{stats.connectedCount !== 1 ? 's' : ''}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedNode(null)
|
||||
setPreviewScreen(null)
|
||||
}}
|
||||
className="ml-auto px-3 py-1 text-sm bg-slate-100 hover:bg-slate-200 rounded-lg"
|
||||
>
|
||||
Zuruecksetzen
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-slate-500 text-sm">
|
||||
Klicke auf einen Screen um den Subtree zu sehen
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category Filter */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4 shadow-sm">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedCategory(null)
|
||||
setSelectedNode(null)
|
||||
setPreviewScreen(null)
|
||||
}}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
selectedCategory === null && !selectedNode
|
||||
? 'bg-slate-800 text-white'
|
||||
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
Alle ({SCREENS.length})
|
||||
</button>
|
||||
{categories.map((key) => {
|
||||
const count = SCREENS.filter(s => s.category === key).length
|
||||
const catColors = COLORS[key] || { bg: '#f1f5f9', border: '#94a3b8', text: '#475569' }
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => {
|
||||
setSelectedCategory(selectedCategory === key ? null : key)
|
||||
setSelectedNode(null)
|
||||
setPreviewScreen(null)
|
||||
}}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium transition-all flex items-center gap-2"
|
||||
style={{
|
||||
background: selectedCategory === key ? catColors.border : catColors.bg,
|
||||
color: selectedCategory === key ? 'white' : catColors.text,
|
||||
}}
|
||||
>
|
||||
<span className="w-3 h-3 rounded-full" style={{ background: catColors.border }} />
|
||||
{LABELS[key]} ({count})
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connected Screens List */}
|
||||
{selectedNode && connectedScreens.length > 1 && (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4 shadow-sm">
|
||||
<div className="text-sm font-medium text-slate-700 mb-3">Verbundene Screens:</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{connectedScreens.map((screen) => {
|
||||
const catColors = COLORS[screen.category] || { bg: '#f1f5f9', border: '#94a3b8', text: '#475569' }
|
||||
const isCurrentNode = screen.id === selectedNode
|
||||
return (
|
||||
<button
|
||||
key={screen.id}
|
||||
onClick={() => {
|
||||
if (screen.url) {
|
||||
window.open(`${baseUrl}${screen.url}`, '_blank')
|
||||
}
|
||||
}}
|
||||
className={`px-3 py-2 rounded-lg text-sm font-medium transition-all flex items-center gap-2 ${
|
||||
isCurrentNode ? 'ring-2 ring-violet-500' : ''
|
||||
}`}
|
||||
style={{
|
||||
background: isCurrentNode ? catColors.border : catColors.bg,
|
||||
color: isCurrentNode ? 'white' : catColors.text,
|
||||
}}
|
||||
>
|
||||
<span>{screen.icon}</span>
|
||||
{screen.name}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Flow Diagram */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden" style={{ height: '500px' }}>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onNodeClick={onNodeClick}
|
||||
onPaneClick={onPaneClick}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.2 }}
|
||||
attributionPosition="bottom-left"
|
||||
>
|
||||
<Controls />
|
||||
<MiniMap
|
||||
nodeColor={(node) => {
|
||||
const screen = SCREENS.find(s => s.id === node.id)
|
||||
const catColors = screen ? COLORS[screen.category] : null
|
||||
return catColors?.border || '#94a3b8'
|
||||
}}
|
||||
maskColor="rgba(0, 0, 0, 0.1)"
|
||||
/>
|
||||
<Background variant={BackgroundVariant.Dots} gap={12} size={1} />
|
||||
|
||||
<Panel position="top-left" className="bg-white/95 p-3 rounded-lg shadow-lg text-xs">
|
||||
<div className="font-medium text-slate-700 mb-2">
|
||||
🛡️ Compliance SDK
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{categories.slice(0, 5).map((key) => {
|
||||
const catColors = COLORS[key] || { bg: '#f1f5f9', border: '#94a3b8' }
|
||||
return (
|
||||
<div key={key} className="flex items-center gap-2">
|
||||
<span
|
||||
className="w-3 h-3 rounded"
|
||||
style={{ background: catColors.bg, border: `1px solid ${catColors.border}` }}
|
||||
/>
|
||||
<span className="text-slate-600">{LABELS[key]}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-2 pt-2 border-t text-slate-400">
|
||||
Klick = Subtree<br/>
|
||||
Doppelklick = Oeffnen
|
||||
</div>
|
||||
</Panel>
|
||||
</ReactFlow>
|
||||
</div>
|
||||
|
||||
{/* Screen List */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden">
|
||||
<div className="px-4 py-3 bg-slate-50 border-b flex items-center justify-between">
|
||||
<h3 className="font-medium text-slate-700">
|
||||
Alle Screens ({SCREENS.length})
|
||||
</h3>
|
||||
<span className="text-xs text-slate-400">{baseUrl}</span>
|
||||
</div>
|
||||
<div className="divide-y max-h-80 overflow-y-auto">
|
||||
{SCREENS
|
||||
.filter(s => !selectedCategory || s.category === selectedCategory)
|
||||
.map((screen) => {
|
||||
const catColors = COLORS[screen.category] || { bg: '#f1f5f9', border: '#94a3b8', text: '#475569' }
|
||||
return (
|
||||
<button
|
||||
key={screen.id}
|
||||
onClick={() => {
|
||||
setSelectedNode(screen.id)
|
||||
setSelectedCategory(null)
|
||||
setPreviewScreen(screen)
|
||||
}}
|
||||
className="w-full flex items-center gap-4 p-3 hover:bg-slate-50 transition-colors text-left"
|
||||
>
|
||||
<span
|
||||
className="w-9 h-9 rounded-lg flex items-center justify-center text-lg"
|
||||
style={{ background: catColors.bg }}
|
||||
>
|
||||
{screen.icon}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-slate-800 text-sm">{screen.name}</div>
|
||||
<div className="text-xs text-slate-500 truncate">{screen.description}</div>
|
||||
</div>
|
||||
<span
|
||||
className="px-2 py-1 rounded text-xs font-medium shrink-0"
|
||||
style={{ background: catColors.bg, color: catColors.text }}
|
||||
>
|
||||
{LABELS[screen.category]}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Sidebar } from '@/components/layout/Sidebar'
|
||||
import { Header } from '@/components/layout/Header'
|
||||
import { Breadcrumbs } from '@/components/common/Breadcrumbs'
|
||||
import { getStoredRole } from '@/lib/roles'
|
||||
|
||||
export default function AdminLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const [sidebarKey, setSidebarKey] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
// Check if role is stored
|
||||
const role = getStoredRole()
|
||||
if (!role) {
|
||||
// Redirect to role selection
|
||||
router.replace('/')
|
||||
} else {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [router])
|
||||
|
||||
const handleRoleChange = () => {
|
||||
// Force sidebar to re-render
|
||||
setSidebarKey(prev => prev + 1)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 flex">
|
||||
{/* Sidebar */}
|
||||
<Sidebar key={sidebarKey} onRoleChange={handleRoleChange} />
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 ml-64 transition-all duration-300">
|
||||
{/* Header */}
|
||||
<Header />
|
||||
|
||||
{/* Page Content */}
|
||||
<main className="p-6">
|
||||
<Breadcrumbs />
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,651 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect, useMemo } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
Course,
|
||||
Lesson,
|
||||
Enrollment,
|
||||
QuizQuestion,
|
||||
COURSE_CATEGORY_INFO,
|
||||
ENROLLMENT_STATUS_INFO,
|
||||
isEnrollmentOverdue,
|
||||
getDaysUntilDeadline
|
||||
} from '@/lib/sdk/academy/types'
|
||||
import {
|
||||
fetchCourse,
|
||||
fetchEnrollments,
|
||||
deleteCourse,
|
||||
submitQuiz,
|
||||
updateLesson,
|
||||
generateVideos,
|
||||
getVideoStatus
|
||||
} from '@/lib/sdk/academy/api'
|
||||
|
||||
type TabId = 'overview' | 'lessons' | 'enrollments' | 'videos'
|
||||
|
||||
export default function CourseDetailPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const courseId = params.id as string
|
||||
|
||||
const [course, setCourse] = useState<Course | null>(null)
|
||||
const [enrollments, setEnrollments] = useState<Enrollment[]>([])
|
||||
const [activeTab, setActiveTab] = useState<TabId>('overview')
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [selectedLesson, setSelectedLesson] = useState<Lesson | null>(null)
|
||||
const [quizAnswers, setQuizAnswers] = useState<Record<string, number>>({})
|
||||
const [quizResult, setQuizResult] = useState<any>(null)
|
||||
const [isSubmittingQuiz, setIsSubmittingQuiz] = useState(false)
|
||||
const [videoStatus, setVideoStatus] = useState<any>(null)
|
||||
const [isGeneratingVideos, setIsGeneratingVideos] = useState(false)
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [editContent, setEditContent] = useState('')
|
||||
const [editTitle, setEditTitle] = useState('')
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [saveMessage, setSaveMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const [courseData, enrollmentData] = await Promise.all([
|
||||
fetchCourse(courseId).catch(() => null),
|
||||
fetchEnrollments(courseId).catch(() => [])
|
||||
])
|
||||
setCourse(courseData)
|
||||
setEnrollments(Array.isArray(enrollmentData) ? enrollmentData : [])
|
||||
if (courseData && courseData.lessons && courseData.lessons.length > 0) {
|
||||
setSelectedLesson(courseData.lessons[0])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load course:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
loadData()
|
||||
}, [courseId])
|
||||
|
||||
const handleDeleteCourse = async () => {
|
||||
if (!confirm('Sind Sie sicher, dass Sie diesen Kurs loeschen moechten? Diese Aktion kann nicht rueckgaengig gemacht werden.')) return
|
||||
try {
|
||||
await deleteCourse(courseId)
|
||||
router.push('/sdk/academy')
|
||||
} catch (error) {
|
||||
console.error('Failed to delete course:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmitQuiz = async () => {
|
||||
if (!selectedLesson) return
|
||||
const questions = selectedLesson.quizQuestions || []
|
||||
const answers = questions.map((q: QuizQuestion) => quizAnswers[q.id] ?? -1)
|
||||
|
||||
setIsSubmittingQuiz(true)
|
||||
try {
|
||||
const result = await submitQuiz(selectedLesson.id, { answers })
|
||||
setQuizResult(result)
|
||||
} catch (error: any) {
|
||||
console.error('Quiz submission failed:', error)
|
||||
setQuizResult({ error: error.message || 'Fehler bei der Auswertung' })
|
||||
} finally {
|
||||
setIsSubmittingQuiz(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStartEdit = () => {
|
||||
if (!selectedLesson) return
|
||||
setEditContent(selectedLesson.contentMarkdown || '')
|
||||
setEditTitle(selectedLesson.title || '')
|
||||
setIsEditing(true)
|
||||
setSaveMessage(null)
|
||||
}
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setIsEditing(false)
|
||||
setSaveMessage(null)
|
||||
}
|
||||
|
||||
const handleSaveLesson = async () => {
|
||||
if (!selectedLesson) return
|
||||
setIsSaving(true)
|
||||
setSaveMessage(null)
|
||||
try {
|
||||
await updateLesson(selectedLesson.id, {
|
||||
title: editTitle,
|
||||
content_url: editContent,
|
||||
})
|
||||
const updatedLesson = { ...selectedLesson, title: editTitle, contentMarkdown: editContent }
|
||||
setSelectedLesson(updatedLesson)
|
||||
if (course) {
|
||||
const updatedLessons = course.lessons.map(l => l.id === updatedLesson.id ? updatedLesson : l)
|
||||
setCourse({ ...course, lessons: updatedLessons })
|
||||
}
|
||||
setIsEditing(false)
|
||||
setSaveMessage({ type: 'success', text: 'Lektion gespeichert.' })
|
||||
} catch (error: any) {
|
||||
setSaveMessage({ type: 'error', text: error.message || 'Fehler beim Speichern.' })
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleApproveLesson = async () => {
|
||||
if (!selectedLesson) return
|
||||
if (!confirm('Lektion fuer Video-Verarbeitung freigeben? Der Text wird als final markiert.')) return
|
||||
setIsSaving(true)
|
||||
setSaveMessage(null)
|
||||
try {
|
||||
await updateLesson(selectedLesson.id, {
|
||||
description: 'approved_for_video',
|
||||
})
|
||||
const updatedLesson = { ...selectedLesson }
|
||||
if (course) {
|
||||
const updatedLessons = course.lessons.map(l => l.id === updatedLesson.id ? updatedLesson : l)
|
||||
setCourse({ ...course, lessons: updatedLessons })
|
||||
}
|
||||
setSaveMessage({ type: 'success', text: 'Lektion fuer Video-Verarbeitung freigegeben.' })
|
||||
} catch (error: any) {
|
||||
setSaveMessage({ type: 'error', text: error.message || 'Fehler bei der Freigabe.' })
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleGenerateVideos = async () => {
|
||||
setIsGeneratingVideos(true)
|
||||
try {
|
||||
const status = await generateVideos(courseId)
|
||||
setVideoStatus(status)
|
||||
} catch (error) {
|
||||
console.error('Video generation failed:', error)
|
||||
} finally {
|
||||
setIsGeneratingVideos(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCheckVideoStatus = async () => {
|
||||
try {
|
||||
const status = await getVideoStatus(courseId)
|
||||
setVideoStatus(status)
|
||||
} catch (error) {
|
||||
console.error('Failed to check video status:', error)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
if (!course) {
|
||||
return (
|
||||
<div className="text-center py-20">
|
||||
<h2 className="text-xl font-semibold text-gray-900">Kurs nicht gefunden</h2>
|
||||
<Link href="/sdk/academy" className="mt-4 inline-block text-purple-600 hover:underline">
|
||||
Zurueck zur Uebersicht
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const categoryInfo = COURSE_CATEGORY_INFO[course.category] || COURSE_CATEGORY_INFO['custom']
|
||||
const sortedLessons = [...(course.lessons || [])].sort((a, b) => a.order - b.order)
|
||||
const completedEnrollments = enrollments.filter(e => e.status === 'completed').length
|
||||
const overdueEnrollments = enrollments.filter(e => isEnrollmentOverdue(e)).length
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
href="/sdk/academy"
|
||||
className="p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg 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="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</Link>
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${categoryInfo.bgColor} ${categoryInfo.color}`}>
|
||||
{categoryInfo.label}
|
||||
</span>
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${
|
||||
course.status === 'published' ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{course.status === 'published' ? 'Veroeffentlicht' : 'Entwurf'}
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">{course.title}</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">{course.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleDeleteCourse}
|
||||
className="px-3 py-2 text-sm text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||
>
|
||||
Loeschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Row */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-4">
|
||||
<div className="text-sm text-gray-500">Lektionen</div>
|
||||
<div className="text-2xl font-bold text-gray-900">{sortedLessons.length}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-4">
|
||||
<div className="text-sm text-gray-500">Dauer</div>
|
||||
<div className="text-2xl font-bold text-gray-900">{course.durationMinutes} Min.</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-4">
|
||||
<div className="text-sm text-gray-500">Teilnehmer</div>
|
||||
<div className="text-2xl font-bold text-blue-600">{enrollments.length}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-4">
|
||||
<div className="text-sm text-gray-500">Abgeschlossen</div>
|
||||
<div className="text-2xl font-bold text-green-600">{completedEnrollments}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-gray-200">
|
||||
<nav className="flex gap-1 -mb-px">
|
||||
{(['overview', 'lessons', 'enrollments', 'videos'] as TabId[]).map(tab => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === tab
|
||||
? 'border-purple-600 text-purple-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{{ overview: 'Uebersicht', lessons: 'Lektionen', enrollments: 'Einschreibungen', videos: 'Videos' }[tab]}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Overview Tab */}
|
||||
{activeTab === 'overview' && (
|
||||
<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-4">Kurs-Details</h3>
|
||||
<dl className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div><dt className="text-gray-500">Bestehensgrenze</dt><dd className="font-medium text-gray-900">{course.passingScore}%</dd></div>
|
||||
<div><dt className="text-gray-500">Pflicht fuer</dt><dd className="font-medium text-gray-900">{course.requiredForRoles?.join(', ') || 'Alle'}</dd></div>
|
||||
<div><dt className="text-gray-500">Erstellt am</dt><dd className="font-medium text-gray-900">{new Date(course.createdAt).toLocaleDateString('de-DE')}</dd></div>
|
||||
<div><dt className="text-gray-500">Aktualisiert am</dt><dd className="font-medium text-gray-900">{new Date(course.updatedAt).toLocaleDateString('de-DE')}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* Lesson List Preview */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Lektionen ({sortedLessons.length})</h3>
|
||||
<div className="space-y-2">
|
||||
{sortedLessons.map((lesson, i) => (
|
||||
<div key={lesson.id} className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-50">
|
||||
<div className="w-8 h-8 rounded-full bg-purple-100 text-purple-600 flex items-center justify-center text-sm font-medium">
|
||||
{i + 1}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-gray-900">{lesson.title}</div>
|
||||
<div className="text-xs text-gray-500">{lesson.durationMinutes} Min. | {lesson.type === 'video' ? 'Video' : lesson.type === 'quiz' ? 'Quiz' : 'Text'}</div>
|
||||
</div>
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${
|
||||
lesson.type === 'quiz' ? 'bg-yellow-100 text-yellow-700' :
|
||||
lesson.type === 'video' ? 'bg-blue-100 text-blue-700' :
|
||||
'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{lesson.type === 'quiz' ? 'Quiz' : lesson.type === 'video' ? 'Video' : 'Text'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Lessons Tab - with content viewer and quiz player */}
|
||||
{activeTab === 'lessons' && (
|
||||
<div className="grid grid-cols-3 gap-6">
|
||||
{/* Lesson Navigation */}
|
||||
<div className="col-span-1 bg-white rounded-xl border border-gray-200 p-4">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-3">Lektionen</h3>
|
||||
<div className="space-y-1">
|
||||
{sortedLessons.map((lesson, i) => (
|
||||
<button
|
||||
key={lesson.id}
|
||||
onClick={() => { setSelectedLesson(lesson); setQuizResult(null); setQuizAnswers({}) }}
|
||||
className={`w-full text-left p-3 rounded-lg text-sm transition-colors ${
|
||||
selectedLesson?.id === lesson.id
|
||||
? 'bg-purple-50 text-purple-700 border border-purple-200'
|
||||
: 'hover:bg-gray-50 text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-400 w-4">{i + 1}.</span>
|
||||
<span className="truncate">{lesson.title}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lesson Content */}
|
||||
<div className="col-span-2 bg-white rounded-xl border border-gray-200 p-6">
|
||||
{selectedLesson ? (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editTitle}
|
||||
onChange={e => setEditTitle(e.target.value)}
|
||||
className="text-xl font-semibold text-gray-900 border border-gray-300 rounded-lg px-3 py-1 flex-1 mr-3"
|
||||
/>
|
||||
) : (
|
||||
<h2 className="text-xl font-semibold text-gray-900">{selectedLesson.title}</h2>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`px-3 py-1 text-xs rounded-full ${
|
||||
selectedLesson.type === 'quiz' ? 'bg-yellow-100 text-yellow-700' :
|
||||
selectedLesson.type === 'video' ? 'bg-blue-100 text-blue-700' :
|
||||
'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{selectedLesson.type === 'quiz' ? 'Quiz' : selectedLesson.type === 'video' ? 'Video' : 'Text'}
|
||||
</span>
|
||||
{selectedLesson.type !== 'quiz' && !isEditing && (
|
||||
<>
|
||||
<button
|
||||
onClick={handleStartEdit}
|
||||
className="px-3 py-1 text-sm bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
Bearbeiten
|
||||
</button>
|
||||
<button
|
||||
onClick={handleApproveLesson}
|
||||
disabled={isSaving}
|
||||
className="px-3 py-1 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Freigeben
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{isEditing && (
|
||||
<>
|
||||
<button
|
||||
onClick={handleCancelEdit}
|
||||
className="px-3 py-1 text-sm bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSaveLesson}
|
||||
disabled={isSaving}
|
||||
className="px-3 py-1 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isSaving ? 'Speichert...' : 'Speichern'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save/Approve Message */}
|
||||
{saveMessage && (
|
||||
<div className={`p-3 rounded-lg text-sm ${
|
||||
saveMessage.type === 'success' ? 'bg-green-50 text-green-700 border border-green-200' : 'bg-red-50 text-red-700 border border-red-200'
|
||||
}`}>
|
||||
{saveMessage.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Video Player */}
|
||||
{selectedLesson.type === 'video' && selectedLesson.videoUrl && (
|
||||
<div className="aspect-video bg-gray-900 rounded-xl overflow-hidden">
|
||||
<video
|
||||
src={selectedLesson.videoUrl}
|
||||
controls
|
||||
className="w-full h-full"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Text Content - Edit Mode */}
|
||||
{isEditing && (selectedLesson.type === 'text' || selectedLesson.type === 'video') && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-gray-500">Inhalt (Markdown)</label>
|
||||
<textarea
|
||||
value={editContent}
|
||||
onChange={e => setEditContent(e.target.value)}
|
||||
rows={20}
|
||||
className="w-full border border-gray-300 rounded-xl p-4 text-sm font-mono text-gray-800 leading-relaxed resize-y focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
placeholder="Markdown-Inhalt der Lektion..."
|
||||
/>
|
||||
<p className="text-xs text-gray-400">Unterstuetzt: # Ueberschrift, ## Unterueberschrift, - Aufzaehlung, **fett**</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Text Content - View Mode */}
|
||||
{!isEditing && (selectedLesson.type === 'text' || selectedLesson.type === 'video') && selectedLesson.contentMarkdown && (
|
||||
<div className="prose prose-sm max-w-none">
|
||||
<div className="whitespace-pre-wrap text-gray-700 leading-relaxed">
|
||||
{selectedLesson.contentMarkdown.split('\n').map((line, i) => {
|
||||
if (line.startsWith('# ')) return <h1 key={i} className="text-2xl font-bold text-gray-900 mt-6 mb-3">{line.slice(2)}</h1>
|
||||
if (line.startsWith('## ')) return <h2 key={i} className="text-xl font-semibold text-gray-900 mt-5 mb-2">{line.slice(3)}</h2>
|
||||
if (line.startsWith('### ')) return <h3 key={i} className="text-lg font-medium text-gray-900 mt-4 mb-2">{line.slice(4)}</h3>
|
||||
if (line.startsWith('- **')) {
|
||||
const parts = line.slice(2).split('**')
|
||||
return <li key={i} className="ml-4 list-disc"><strong>{parts[1]}</strong>{parts[2] || ''}</li>
|
||||
}
|
||||
if (line.startsWith('- ')) return <li key={i} className="ml-4 list-disc">{line.slice(2)}</li>
|
||||
if (line.trim() === '') return <br key={i} />
|
||||
return <p key={i} className="mb-2">{line}</p>
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quiz Player */}
|
||||
{selectedLesson.type === 'quiz' && selectedLesson.quizQuestions && (
|
||||
<div className="space-y-6">
|
||||
{selectedLesson.quizQuestions.map((q: QuizQuestion, qi: number) => (
|
||||
<div key={q.id} className="bg-gray-50 rounded-xl p-5">
|
||||
<h4 className="font-medium text-gray-900 mb-3">Frage {qi + 1}: {q.question}</h4>
|
||||
<div className="space-y-2">
|
||||
{q.options.map((option: string, oi: number) => {
|
||||
const isSelected = quizAnswers[q.id] === oi
|
||||
const showResult = quizResult && !quizResult.error
|
||||
const isCorrect = showResult && quizResult.results?.[qi]?.correct
|
||||
const wasSelected = showResult && isSelected
|
||||
|
||||
let bgClass = 'bg-white border-gray-200 hover:border-purple-300'
|
||||
if (isSelected && !showResult) bgClass = 'bg-purple-50 border-purple-500'
|
||||
if (showResult && oi === q.correctOptionIndex) bgClass = 'bg-green-50 border-green-500'
|
||||
if (showResult && wasSelected && !isCorrect) bgClass = 'bg-red-50 border-red-500'
|
||||
|
||||
return (
|
||||
<button
|
||||
key={oi}
|
||||
onClick={() => !quizResult && setQuizAnswers({ ...quizAnswers, [q.id]: oi })}
|
||||
disabled={!!quizResult}
|
||||
className={`w-full text-left p-3 rounded-lg border-2 transition-all ${bgClass}`}
|
||||
>
|
||||
<span className="text-sm text-gray-700">{String.fromCharCode(65 + oi)}) {option}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{quizResult && !quizResult.error && quizResult.results?.[qi] && (
|
||||
<div className={`mt-3 p-3 rounded-lg text-sm ${
|
||||
quizResult.results[qi].correct ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'
|
||||
}`}>
|
||||
{quizResult.results[qi].correct ? 'Richtig!' : 'Falsch.'} {q.explanation}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Quiz Submit / Result */}
|
||||
{!quizResult ? (
|
||||
<button
|
||||
onClick={handleSubmitQuiz}
|
||||
disabled={isSubmittingQuiz || Object.keys(quizAnswers).length < (selectedLesson.quizQuestions?.length || 0)}
|
||||
className="w-full py-3 bg-purple-600 text-white rounded-xl hover:bg-purple-700 transition-colors disabled:opacity-50 font-medium"
|
||||
>
|
||||
{isSubmittingQuiz ? 'Wird ausgewertet...' : 'Quiz auswerten'}
|
||||
</button>
|
||||
) : quizResult.error ? (
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl p-4 text-sm text-red-700">{quizResult.error}</div>
|
||||
) : (
|
||||
<div className={`rounded-xl p-6 text-center ${quizResult.passed ? 'bg-green-50 border border-green-200' : 'bg-red-50 border border-red-200'}`}>
|
||||
<div className={`text-3xl font-bold ${quizResult.passed ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{quizResult.score}%
|
||||
</div>
|
||||
<div className={`text-sm mt-1 ${quizResult.passed ? 'text-green-700' : 'text-red-700'}`}>
|
||||
{quizResult.passed ? 'Bestanden!' : 'Nicht bestanden'} — {quizResult.correctAnswers}/{quizResult.totalQuestions} richtig
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { setQuizResult(null); setQuizAnswers({}) }}
|
||||
className="mt-4 px-4 py-2 text-sm bg-white rounded-lg border hover:bg-gray-50"
|
||||
>
|
||||
Quiz wiederholen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 text-center py-10">Waehlen Sie eine Lektion aus.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Enrollments Tab */}
|
||||
{activeTab === 'enrollments' && (
|
||||
<div className="space-y-4">
|
||||
{overdueEnrollments > 0 && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl p-4 text-sm text-red-700">
|
||||
{overdueEnrollments} ueberfaellige Einschreibung(en)
|
||||
</div>
|
||||
)}
|
||||
{enrollments.length === 0 ? (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
|
||||
<p className="text-gray-500">Noch keine Einschreibungen fuer diesen Kurs.</p>
|
||||
</div>
|
||||
) : (
|
||||
enrollments.map(enrollment => {
|
||||
const statusInfo = ENROLLMENT_STATUS_INFO[enrollment.status]
|
||||
const overdue = isEnrollmentOverdue(enrollment)
|
||||
const daysUntil = getDaysUntilDeadline(enrollment.deadline)
|
||||
return (
|
||||
<div key={enrollment.id} className={`bg-white rounded-xl border-2 p-5 ${overdue ? 'border-red-200' : 'border-gray-200'}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full ${statusInfo?.bgColor} ${statusInfo?.color}`}>
|
||||
{statusInfo?.label}
|
||||
</span>
|
||||
{overdue && <span className="text-xs text-red-600">Ueberfaellig</span>}
|
||||
</div>
|
||||
<div className="font-medium text-gray-900">{enrollment.userName}</div>
|
||||
<div className="text-sm text-gray-500">{enrollment.userEmail}</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-2xl font-bold text-gray-900">{enrollment.progress}%</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{enrollment.status === 'completed' ? 'Abgeschlossen' : `${daysUntil > 0 ? daysUntil + ' Tage verbleibend' : Math.abs(daysUntil) + ' Tage ueberfaellig'}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 w-full h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full ${enrollment.progress === 100 ? 'bg-green-500' : overdue ? 'bg-red-500' : 'bg-purple-500'}`}
|
||||
style={{ width: `${enrollment.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Videos Tab */}
|
||||
{activeTab === 'videos' && (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Video-Generierung</h3>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleCheckVideoStatus}
|
||||
className="px-4 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
Status pruefen
|
||||
</button>
|
||||
<button
|
||||
onClick={handleGenerateVideos}
|
||||
disabled={isGeneratingVideos}
|
||||
className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50"
|
||||
>
|
||||
{isGeneratingVideos ? 'Wird gestartet...' : 'Videos generieren'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4 mb-4 text-sm text-blue-700">
|
||||
Videos werden mit ElevenLabs (Stimme) und HeyGen (Avatar) generiert.
|
||||
Konfigurieren Sie die API-Keys in den Umgebungsvariablen.
|
||||
</div>
|
||||
|
||||
{videoStatus && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-500">Gesamtstatus:</span>
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${
|
||||
videoStatus.status === 'completed' ? 'bg-green-100 text-green-700' :
|
||||
videoStatus.status === 'processing' ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{videoStatus.status}
|
||||
</span>
|
||||
</div>
|
||||
{videoStatus.lessons?.map((ls: any) => (
|
||||
<div key={ls.lessonId} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<span className="text-sm text-gray-700">Lektion {ls.lessonId.slice(-4)}</span>
|
||||
<span className={`text-xs ${ls.status === 'completed' ? 'text-green-600' : 'text-gray-500'}`}>
|
||||
{ls.status}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!videoStatus && (
|
||||
<p className="text-sm text-gray-500 text-center py-8">
|
||||
Klicken Sie auf "Videos generieren" um den Prozess zu starten.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,759 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect, useMemo } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useSDK } from '@/lib/sdk'
|
||||
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
||||
import {
|
||||
Course,
|
||||
CourseCategory,
|
||||
Enrollment,
|
||||
EnrollmentStatus,
|
||||
AcademyStatistics,
|
||||
COURSE_CATEGORY_INFO,
|
||||
ENROLLMENT_STATUS_INFO,
|
||||
isEnrollmentOverdue,
|
||||
getDaysUntilDeadline
|
||||
} from '@/lib/sdk/academy/types'
|
||||
import { fetchSDKAcademyList, generateAllCourses } from '@/lib/sdk/academy/api'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
type TabId = 'overview' | 'courses' | 'enrollments' | 'certificates' | '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'
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
function CourseCard({ course, enrollmentCount }: { course: Course; enrollmentCount: number }) {
|
||||
const categoryInfo = COURSE_CATEGORY_INFO[course.category] || COURSE_CATEGORY_INFO['custom']
|
||||
|
||||
return (
|
||||
<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">
|
||||
{/* Header Badges */}
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* Course Title */}
|
||||
<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>
|
||||
|
||||
{/* Course Meta */}
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Side - Roles */}
|
||||
<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>
|
||||
|
||||
{/* Footer */}
|
||||
<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>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="px-3 py-1 text-sm text-purple-600 hover:bg-purple-50 rounded-lg transition-colors">
|
||||
Details
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
function EnrollmentCard({ enrollment, courseName }: { enrollment: Enrollment; courseName: string }) {
|
||||
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')}
|
||||
</div>
|
||||
{enrollment.completedAt && (
|
||||
<div className="text-sm text-green-600">
|
||||
Abgeschlossen: {new Date(enrollment.completedAt).toLocaleDateString('de-DE')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN PAGE
|
||||
// =============================================================================
|
||||
|
||||
export default function AcademyPage() {
|
||||
const { state } = useSDK()
|
||||
const [activeTab, setActiveTab] = useState<TabId>('overview')
|
||||
const [courses, setCourses] = useState<Course[]>([])
|
||||
const [enrollments, setEnrollments] = useState<Enrollment[]>([])
|
||||
const [statistics, setStatistics] = useState<AcademyStatistics | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isGenerating, setIsGenerating] = useState(false)
|
||||
const [generateResult, setGenerateResult] = useState<{ generated: number; skipped: number; errors: string[] } | null>(null)
|
||||
|
||||
// Filters
|
||||
const [selectedCategory, setSelectedCategory] = useState<CourseCategory | 'all'>('all')
|
||||
const [selectedStatus, setSelectedStatus] = useState<EnrollmentStatus | 'all'>('all')
|
||||
|
||||
// Load data from SDK backend
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [])
|
||||
|
||||
// Calculate tab counts
|
||||
const tabCounts = useMemo(() => {
|
||||
return {
|
||||
courses: courses.length,
|
||||
enrollments: enrollments.filter(e => e.status !== 'completed').length,
|
||||
certificates: enrollments.filter(e => e.certificateId).length,
|
||||
overdue: enrollments.filter(e => isEnrollmentOverdue(e)).length
|
||||
}
|
||||
}, [courses, enrollments])
|
||||
|
||||
// Filtered courses
|
||||
const filteredCourses = useMemo(() => {
|
||||
let filtered = [...courses]
|
||||
|
||||
if (selectedCategory !== 'all') {
|
||||
filtered = filtered.filter(c => c.category === selectedCategory)
|
||||
}
|
||||
|
||||
return filtered.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
|
||||
}, [courses, selectedCategory])
|
||||
|
||||
// Filtered enrollments
|
||||
const filteredEnrollments = useMemo(() => {
|
||||
let filtered = [...enrollments]
|
||||
|
||||
if (selectedStatus !== 'all') {
|
||||
filtered = filtered.filter(e => e.status === selectedStatus)
|
||||
}
|
||||
|
||||
// Sort: overdue first, then by deadline
|
||||
return filtered.sort((a, b) => {
|
||||
const aOverdue = isEnrollmentOverdue(a) ? -1 : 0
|
||||
const bOverdue = isEnrollmentOverdue(b) ? -1 : 0
|
||||
if (aOverdue !== bOverdue) return aOverdue - bOverdue
|
||||
return getDaysUntilDeadline(a.deadline) - getDaysUntilDeadline(b.deadline)
|
||||
})
|
||||
}, [enrollments, selectedStatus])
|
||||
|
||||
// Enrollment counts per course
|
||||
const enrollmentCountByCourseId = useMemo(() => {
|
||||
const counts: Record<string, number> = {}
|
||||
enrollments.forEach(e => {
|
||||
counts[e.courseId] = (counts[e.courseId] || 0) + 1
|
||||
})
|
||||
return counts
|
||||
}, [enrollments])
|
||||
|
||||
// Course name lookup
|
||||
const courseNameById = useMemo(() => {
|
||||
const map: Record<string, string> = {}
|
||||
courses.forEach(c => { map[c.id] = c.title })
|
||||
return map
|
||||
}, [courses])
|
||||
|
||||
const tabs: Tab[] = [
|
||||
{ id: 'overview', label: 'Uebersicht' },
|
||||
{ id: 'courses', label: 'Kurse', count: tabCounts.courses, countColor: 'bg-blue-100 text-blue-600' },
|
||||
{ id: 'enrollments', label: 'Einschreibungen', count: tabCounts.enrollments, countColor: 'bg-yellow-100 text-yellow-600' },
|
||||
{ id: 'certificates', label: 'Zertifikate', count: tabCounts.certificates, countColor: 'bg-green-100 text-green-600' },
|
||||
{ id: 'settings', label: 'Einstellungen' }
|
||||
]
|
||||
|
||||
const stepInfo = STEP_EXPLANATIONS['academy']
|
||||
|
||||
const loadData = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const data = await fetchSDKAcademyList()
|
||||
setCourses(data.courses)
|
||||
setEnrollments(data.enrollments)
|
||||
setStatistics(data.statistics)
|
||||
} catch (error) {
|
||||
console.error('Failed to load Academy data:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleGenerateAll = async () => {
|
||||
setIsGenerating(true)
|
||||
setGenerateResult(null)
|
||||
try {
|
||||
const result = await generateAllCourses()
|
||||
setGenerateResult({ generated: result.generated, skipped: result.skipped, errors: result.errors || [] })
|
||||
// Reload data to show new courses
|
||||
await loadData()
|
||||
} catch (error) {
|
||||
console.error('Failed to generate courses:', error)
|
||||
setGenerateResult({ generated: 0, skipped: 0, errors: [error instanceof Error ? error.message : 'Fehler bei der Generierung'] })
|
||||
} finally {
|
||||
setIsGenerating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const clearFilters = () => {
|
||||
setSelectedCategory('all')
|
||||
setSelectedStatus('all')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Step Header */}
|
||||
<StepHeader
|
||||
stepId="academy"
|
||||
title={stepInfo?.title || 'Compliance Academy'}
|
||||
description={stepInfo?.description || 'E-Learning Plattform fuer Compliance-Schulungen'}
|
||||
explanation={stepInfo?.explanation}
|
||||
tips={stepInfo?.tips}
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleGenerateAll}
|
||||
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>
|
||||
</StepHeader>
|
||||
|
||||
{/* Generation Result */}
|
||||
{generateResult && (
|
||||
<div className={`p-4 rounded-lg border ${generateResult.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">{generateResult.generated} Kurse generiert</span>
|
||||
<span className="text-gray-500">{generateResult.skipped} uebersprungen</span>
|
||||
{generateResult.errors.length > 0 && (
|
||||
<span className="text-red-600">{generateResult.errors.length} Fehler</span>
|
||||
)}
|
||||
</div>
|
||||
{generateResult.errors.length > 0 && (
|
||||
<div className="mt-2 text-xs text-red-600">
|
||||
{generateResult.errors.map((err, i) => <div key={i}>{err}</div>)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<TabNavigation
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
/>
|
||||
|
||||
{/* Loading State */}
|
||||
{isLoading ? (
|
||||
<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>
|
||||
) : activeTab === 'settings' ? (
|
||||
/* Settings Tab */
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-8 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="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.065 2.572c1.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.572 1.065c-.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.065-2.572c-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>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Einstellungen</h3>
|
||||
<p className="mt-2 text-gray-500">
|
||||
Academy-Einstellungen, E-Mail-Benachrichtigungen und Kurs-Vorlagen
|
||||
werden in einer spaeteren Version verfuegbar sein.
|
||||
</p>
|
||||
</div>
|
||||
) : activeTab === 'certificates' ? (
|
||||
/* Certificates Tab Placeholder */
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-8 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">Zertifikate</h3>
|
||||
<p className="mt-2 text-gray-500">
|
||||
Zertifikate werden automatisch nach erfolgreichem Kursabschluss generiert.
|
||||
Die Zertifikatsverwaltung wird in einer spaeteren Version verfuegbar sein.
|
||||
</p>
|
||||
{tabCounts.certificates > 0 && (
|
||||
<p className="mt-2 text-sm text-purple-600 font-medium">
|
||||
{tabCounts.certificates} Zertifikat(e) vorhanden
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Statistics (Overview Tab) */}
|
||||
{activeTab === 'overview' && statistics && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
label="Kurse gesamt"
|
||||
value={statistics.totalCourses}
|
||||
color="gray"
|
||||
/>
|
||||
<StatCard
|
||||
label="Aktive Teilnehmer"
|
||||
value={(statistics.byStatus?.in_progress || 0) + (statistics.byStatus?.not_started || 0)}
|
||||
color="blue"
|
||||
/>
|
||||
<StatCard
|
||||
label="Abschlussrate"
|
||||
value={`${statistics.completionRate}%`}
|
||||
color="green"
|
||||
/>
|
||||
<StatCard
|
||||
label="Ueberfaellig"
|
||||
value={statistics.overdueCount}
|
||||
color={statistics.overdueCount > 0 ? 'red' : 'green'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Overdue Alert */}
|
||||
{tabCounts.overdue > 0 && (
|
||||
<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: {tabCounts.overdue} ueberfaellige Schulung(en)
|
||||
</h4>
|
||||
<p className="text-sm text-red-600">
|
||||
Mitarbeiter haben Pflichtschulungen nicht fristgerecht abgeschlossen. Handeln Sie umgehend.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setActiveTab('enrollments')
|
||||
setSelectedStatus('all')
|
||||
}}
|
||||
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 (Overview Tab) */}
|
||||
{activeTab === 'overview' && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<FilterBar
|
||||
selectedCategory={selectedCategory}
|
||||
selectedStatus={selectedStatus}
|
||||
onCategoryChange={setSelectedCategory}
|
||||
onStatusChange={setSelectedStatus}
|
||||
onClear={clearFilters}
|
||||
/>
|
||||
|
||||
{/* Courses Tab */}
|
||||
{(activeTab === 'overview' || activeTab === 'courses') && (
|
||||
<div className="space-y-4">
|
||||
{activeTab === 'courses' && (
|
||||
<h2 className="text-lg font-semibold text-gray-900">Kurse ({filteredCourses.length})</h2>
|
||||
)}
|
||||
{filteredCourses.map(course => (
|
||||
<CourseCard
|
||||
key={course.id}
|
||||
course={course}
|
||||
enrollmentCount={enrollmentCountByCourseId[course.id] || 0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Enrollments Tab */}
|
||||
{activeTab === 'enrollments' && (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Einschreibungen ({filteredEnrollments.length})</h2>
|
||||
{filteredEnrollments.map(enrollment => (
|
||||
<EnrollmentCard
|
||||
key={enrollment.id}
|
||||
enrollment={enrollment}
|
||||
courseName={courseNameById[enrollment.courseId] || 'Unbekannter Kurs'}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty States */}
|
||||
{activeTab === 'courses' && filteredCourses.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="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={clearFilters}
|
||||
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>
|
||||
)}
|
||||
|
||||
{activeTab === 'enrollments' && filteredEnrollments.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="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={clearFilters}
|
||||
className="mt-4 px-4 py-2 text-purple-600 hover:bg-purple-50 rounded-lg transition-colors"
|
||||
>
|
||||
Filter zuruecksetzen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,596 +0,0 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* UCCA System Documentation Page (SDK Version)
|
||||
*
|
||||
* Displays architecture documentation, auditor information,
|
||||
* and transparency data for the UCCA compliance system.
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
type DocTab = 'overview' | 'architecture' | 'auditor' | 'rules' | 'legal-corpus'
|
||||
|
||||
interface Rule {
|
||||
code: string
|
||||
category: string
|
||||
title: string
|
||||
description: string
|
||||
severity: string
|
||||
gdpr_ref: string
|
||||
rationale?: string
|
||||
risk_add?: number
|
||||
}
|
||||
|
||||
interface Pattern {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
benefit?: string
|
||||
effort?: string
|
||||
risk_reduction?: number
|
||||
}
|
||||
|
||||
interface Control {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
gdpr_ref?: string
|
||||
effort?: string
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// API Configuration
|
||||
// ============================================================================
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_SDK_API_URL || 'https://macmini:8090'
|
||||
|
||||
// ============================================================================
|
||||
// Main Component
|
||||
// ============================================================================
|
||||
|
||||
export default function DocumentationPage() {
|
||||
const [activeTab, setActiveTab] = useState<DocTab>('overview')
|
||||
const [rules, setRules] = useState<Rule[]>([])
|
||||
const [patterns, setPatterns] = useState<Pattern[]>([])
|
||||
const [controls, setControls] = useState<Control[]>([])
|
||||
const [policyVersion, setPolicyVersion] = useState<string>('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const rulesRes = await fetch(`${API_BASE}/sdk/v1/ucca/rules`, {
|
||||
headers: { 'X-Tenant-ID': '00000000-0000-0000-0000-000000000000' }
|
||||
})
|
||||
if (rulesRes.ok) {
|
||||
const rulesData = await rulesRes.json()
|
||||
setRules(rulesData.rules || [])
|
||||
setPolicyVersion(rulesData.policy_version || '')
|
||||
}
|
||||
|
||||
const patternsRes = await fetch(`${API_BASE}/sdk/v1/ucca/patterns`, {
|
||||
headers: { 'X-Tenant-ID': '00000000-0000-0000-0000-000000000000' }
|
||||
})
|
||||
if (patternsRes.ok) {
|
||||
const patternsData = await patternsRes.json()
|
||||
setPatterns(patternsData.patterns || [])
|
||||
}
|
||||
|
||||
const controlsRes = await fetch(`${API_BASE}/sdk/v1/ucca/controls`, {
|
||||
headers: { 'X-Tenant-ID': '00000000-0000-0000-0000-000000000000' }
|
||||
})
|
||||
if (controlsRes.ok) {
|
||||
const controlsData = await controlsRes.json()
|
||||
setControls(controlsData.controls || [])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch documentation data:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchData()
|
||||
}, [])
|
||||
|
||||
// ============================================================================
|
||||
// Tab Content Renderers
|
||||
// ============================================================================
|
||||
|
||||
const renderOverview = () => (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6 shadow-sm">
|
||||
<h3 className="font-semibold text-slate-800 mb-2">Deterministische Regeln</h3>
|
||||
<div className="text-3xl font-bold text-purple-600">{rules.length}</div>
|
||||
<p className="text-sm text-slate-500 mt-2">
|
||||
Alle Entscheidungen basieren auf transparenten, nachvollziehbaren Regeln.
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6 shadow-sm">
|
||||
<h3 className="font-semibold text-slate-800 mb-2">Architektur-Patterns</h3>
|
||||
<div className="text-3xl font-bold text-green-600">{patterns.length}</div>
|
||||
<p className="text-sm text-slate-500 mt-2">
|
||||
Best-Practice-Loesungen fuer datenschutzkonforme KI-Systeme.
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6 shadow-sm">
|
||||
<h3 className="font-semibold text-slate-800 mb-2">Compliance-Kontrollen</h3>
|
||||
<div className="text-3xl font-bold text-blue-600">{controls.length}</div>
|
||||
<p className="text-sm text-slate-500 mt-2">
|
||||
Technische und organisatorische Massnahmen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gradient-to-br from-purple-50 to-blue-50 rounded-xl border border-purple-200 p-6">
|
||||
<h3 className="font-semibold text-purple-800 text-lg mb-4">Was ist UCCA?</h3>
|
||||
<div className="prose prose-sm max-w-none text-slate-700">
|
||||
<p>
|
||||
<strong>UCCA (Use-Case Compliance & Feasibility Advisor)</strong> ist ein deterministisches
|
||||
Compliance-Pruefwerkzeug, das Organisationen bei der Bewertung geplanter KI-Anwendungsfaelle
|
||||
hinsichtlich ihrer datenschutzrechtlichen Zulaessigkeit unterstuetzt.
|
||||
</p>
|
||||
<h4 className="text-purple-700 mt-4">Kernprinzipien</h4>
|
||||
<ul className="space-y-2">
|
||||
<li>
|
||||
<strong>Determinismus:</strong> Alle Entscheidungen basieren auf transparenten Regeln.
|
||||
Die KI trifft KEINE autonomen Entscheidungen.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Transparenz:</strong> Alle Regeln, Kontrollen und Patterns sind einsehbar.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Human-in-the-Loop:</strong> Kritische Entscheidungen erfordern immer
|
||||
menschliche Pruefung durch DSB oder Legal.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Rechtsgrundlage:</strong> Jede Regel referenziert konkrete DSGVO-Artikel.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-xl p-6">
|
||||
<h3 className="font-semibold text-amber-800 mb-3">
|
||||
Wichtiger Hinweis zur KI-Nutzung
|
||||
</h3>
|
||||
<p className="text-amber-700">
|
||||
Das System verwendet KI (LLM) <strong>ausschliesslich zur Erklaerung</strong> bereits
|
||||
getroffener Regelentscheidungen. Die eigentliche Compliance-Bewertung erfolgt
|
||||
<strong> rein deterministisch</strong> durch die Policy Engine. BLOCK-Entscheidungen
|
||||
koennen NICHT durch KI ueberschrieben werden.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const renderArchitecture = () => (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="font-semibold text-slate-800 text-lg mb-4">Systemarchitektur</h3>
|
||||
|
||||
<div className="bg-slate-900 text-green-400 p-6 rounded-lg font-mono text-sm overflow-x-auto">
|
||||
<pre>{`
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ Frontend (Next.js) │
|
||||
│ admin-v2:3000/sdk/advisory-board │
|
||||
└───────────────────────────────────┬─────────────────────────────────┘
|
||||
│ HTTPS
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ AI Compliance SDK (Go) │
|
||||
│ Port 8090 │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Policy Engine │ │
|
||||
│ │ ┌───────────────────────────────────────────────────────┐ │ │
|
||||
│ │ │ YAML-basierte Regeln (ucca_policy_v1.yaml) │ │ │
|
||||
│ │ │ ~45 Regeln in 7 Kategorien │ │ │
|
||||
│ │ │ Deterministisch - Kein LLM in Entscheidungslogik │ │ │
|
||||
│ │ └───────────────────────────────────────────────────────┘ │ │
|
||||
│ │ │ │ │
|
||||
│ │ ▼ │ │
|
||||
│ │ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ │ │
|
||||
│ │ │ Controls │ │ Patterns │ │ Examples │ │ │
|
||||
│ │ │ Library │ │ Library │ │ Library │ │ │
|
||||
│ │ └────────────────┘ └────────────────┘ └────────────────┘ │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌──────────────────┐ ┌──────────────────┐ │
|
||||
│ │ LLM Integration │ │ Legal RAG │──────┐ │
|
||||
│ │ (nur Explain) │ │ Client │ │ │
|
||||
│ └──────────────────┘ └──────────────────┘ │ │
|
||||
└─────────────────────────────┬────────────────────┼──────────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ Datenschicht │
|
||||
│ ┌────────────────────┐ ┌────────────────────┐ │
|
||||
│ │ PostgreSQL │ │ Qdrant │ │
|
||||
│ │ (Assessments, │ │ (Legal Corpus, │ │
|
||||
│ │ Escalations) │ │ 2,274 Chunks) │ │
|
||||
│ └────────────────────┘ └────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
`}</pre>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="p-4 bg-blue-50 rounded-lg border border-blue-200">
|
||||
<h4 className="font-medium text-blue-800 mb-2">Datenfluss</h4>
|
||||
<ol className="text-sm text-blue-700 list-decimal list-inside space-y-1">
|
||||
<li>Benutzer beschreibt Use Case im Frontend</li>
|
||||
<li>Policy Engine evaluiert gegen alle Regeln</li>
|
||||
<li>Ergebnis mit Controls + Patterns zurueck</li>
|
||||
<li>Optional: LLM erklaert das Ergebnis</li>
|
||||
<li>Bei Risiko: Automatische Eskalation</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div className="p-4 bg-green-50 rounded-lg border border-green-200">
|
||||
<h4 className="font-medium text-green-800 mb-2">Sicherheitsmerkmale</h4>
|
||||
<ul className="text-sm text-green-700 list-disc list-inside space-y-1">
|
||||
<li>TLS 1.3 Verschluesselung</li>
|
||||
<li>RBAC mit Tenant-Isolation</li>
|
||||
<li>JWT-basierte Authentifizierung</li>
|
||||
<li>Audit-Trail aller Aktionen</li>
|
||||
<li>Keine Rohtext-Speicherung (nur Hash)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="font-semibold text-slate-800 text-lg mb-4">Eskalations-Workflow</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200">
|
||||
<th className="text-left py-2 px-3 font-medium text-slate-600">Level</th>
|
||||
<th className="text-left py-2 px-3 font-medium text-slate-600">Ausloeser</th>
|
||||
<th className="text-left py-2 px-3 font-medium text-slate-600">Pruefer</th>
|
||||
<th className="text-left py-2 px-3 font-medium text-slate-600">SLA</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className="border-b border-slate-100 bg-green-50">
|
||||
<td className="py-2 px-3 font-medium text-green-700">E0</td>
|
||||
<td className="py-2 px-3 text-slate-600">Nur INFO-Regeln, Risiko < 20</td>
|
||||
<td className="py-2 px-3 text-slate-600">Automatisch</td>
|
||||
<td className="py-2 px-3 text-slate-600">-</td>
|
||||
</tr>
|
||||
<tr className="border-b border-slate-100 bg-yellow-50">
|
||||
<td className="py-2 px-3 font-medium text-yellow-700">E1</td>
|
||||
<td className="py-2 px-3 text-slate-600">WARN-Regeln, Risiko 20-40</td>
|
||||
<td className="py-2 px-3 text-slate-600">Team-Lead</td>
|
||||
<td className="py-2 px-3 text-slate-600">24h / 72h</td>
|
||||
</tr>
|
||||
<tr className="border-b border-slate-100 bg-orange-50">
|
||||
<td className="py-2 px-3 font-medium text-orange-700">E2</td>
|
||||
<td className="py-2 px-3 text-slate-600">Art. 9 Daten, DSFA empfohlen, Risiko 40-60</td>
|
||||
<td className="py-2 px-3 text-slate-600">DSB</td>
|
||||
<td className="py-2 px-3 text-slate-600">8h / 48h</td>
|
||||
</tr>
|
||||
<tr className="bg-red-50">
|
||||
<td className="py-2 px-3 font-medium text-red-700">E3</td>
|
||||
<td className="py-2 px-3 text-slate-600">BLOCK-Regeln, Art. 22, Risiko > 60</td>
|
||||
<td className="py-2 px-3 text-slate-600">DSB + Legal</td>
|
||||
<td className="py-2 px-3 text-slate-600">4h / 24h</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const renderAuditorInfo = () => (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="font-semibold text-slate-800 text-lg mb-4">
|
||||
Dokumentation fuer externe Auditoren
|
||||
</h3>
|
||||
<p className="text-slate-600 mb-4">
|
||||
Diese Dokumentation erfuellt die Anforderungen nach Art. 30 DSGVO (Verzeichnis von
|
||||
Verarbeitungstaetigkeiten) und dient als Grundlage fuer Audits nach Art. 32 DSGVO.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-slate-50 rounded-lg border border-slate-200">
|
||||
<h4 className="font-medium text-slate-800 mb-2">1. Zweck des Systems</h4>
|
||||
<p className="text-sm text-slate-600">
|
||||
UCCA ist ein Compliance-Pruefwerkzeug zur Bewertung geplanter KI-Anwendungsfaelle
|
||||
hinsichtlich ihrer datenschutzrechtlichen Zulaessigkeit.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-slate-50 rounded-lg border border-slate-200">
|
||||
<h4 className="font-medium text-slate-800 mb-2">2. Rechtsgrundlage</h4>
|
||||
<ul className="text-sm text-slate-600 list-disc list-inside space-y-1">
|
||||
<li><strong>Art. 6 Abs. 1 lit. c DSGVO</strong> - Erfuellung rechtlicher Verpflichtungen</li>
|
||||
<li><strong>Art. 6 Abs. 1 lit. f DSGVO</strong> - Berechtigte Interessen (Compliance-Management)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-slate-50 rounded-lg border border-slate-200">
|
||||
<h4 className="font-medium text-slate-800 mb-2">3. Verarbeitete Datenkategorien</h4>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm mt-2">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200">
|
||||
<th className="text-left py-2 px-2 font-medium text-slate-600">Kategorie</th>
|
||||
<th className="text-left py-2 px-2 font-medium text-slate-600">Speicherung</th>
|
||||
<th className="text-left py-2 px-2 font-medium text-slate-600">Aufbewahrung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-slate-600">
|
||||
<tr className="border-b border-slate-100">
|
||||
<td className="py-2 px-2">Use-Case-Beschreibung</td>
|
||||
<td className="py-2 px-2">Nur Hash (SHA-256)</td>
|
||||
<td className="py-2 px-2">10 Jahre</td>
|
||||
</tr>
|
||||
<tr className="border-b border-slate-100">
|
||||
<td className="py-2 px-2">Bewertungsergebnis</td>
|
||||
<td className="py-2 px-2">Vollstaendig</td>
|
||||
<td className="py-2 px-2">10 Jahre</td>
|
||||
</tr>
|
||||
<tr className="border-b border-slate-100">
|
||||
<td className="py-2 px-2">Audit-Trail</td>
|
||||
<td className="py-2 px-2">Vollstaendig</td>
|
||||
<td className="py-2 px-2">10 Jahre</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2 px-2">Eskalations-Historie</td>
|
||||
<td className="py-2 px-2">Vollstaendig</td>
|
||||
<td className="py-2 px-2">10 Jahre</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-slate-50 rounded-lg border border-slate-200">
|
||||
<h4 className="font-medium text-slate-800 mb-2">4. Keine autonomen KI-Entscheidungen</h4>
|
||||
<p className="text-sm text-slate-600">
|
||||
Das System trifft <strong>KEINE automatisierten Einzelentscheidungen</strong> im Sinne
|
||||
von Art. 22 DSGVO, da:
|
||||
</p>
|
||||
<ul className="text-sm text-slate-600 list-disc list-inside mt-2 space-y-1">
|
||||
<li>Regelauswertung ist keine rechtlich bindende Entscheidung</li>
|
||||
<li>Alle kritischen Faelle werden menschlich geprueft (E1-E3)</li>
|
||||
<li>BLOCK-Entscheidungen erfordern immer menschliche Freigabe</li>
|
||||
<li>Betroffene haben Anfechtungsmoeglichkeit ueber Eskalation</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-green-50 rounded-lg border border-green-200">
|
||||
<h4 className="font-medium text-green-800 mb-2">5. Technische und Organisatorische Massnahmen</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<strong className="text-green-700">Vertraulichkeit</strong>
|
||||
<ul className="text-green-700 list-disc list-inside mt-1">
|
||||
<li>RBAC mit Tenant-Isolation</li>
|
||||
<li>TLS 1.3 Verschluesselung</li>
|
||||
<li>AES-256 at rest</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<strong className="text-green-700">Integritaet</strong>
|
||||
<ul className="text-green-700 list-disc list-inside mt-1">
|
||||
<li>Unveraenderlicher Audit-Trail</li>
|
||||
<li>Policy-Versionierung</li>
|
||||
<li>Input-Validierung</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const renderRulesTab = () => (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold text-slate-800 text-lg">Regel-Katalog</h3>
|
||||
<p className="text-sm text-slate-500">Policy Version: {policyVersion}</p>
|
||||
</div>
|
||||
<div className="text-sm text-slate-500">
|
||||
{rules.length} Regeln insgesamt
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-8 text-slate-500">Lade Regeln...</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{Array.from(new Set(rules.map(r => r.category))).map(category => (
|
||||
<div key={category} className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<div className="px-4 py-3 bg-slate-50 border-b border-slate-200">
|
||||
<h4 className="font-medium text-slate-800">{category}</h4>
|
||||
<p className="text-xs text-slate-500">
|
||||
{rules.filter(r => r.category === category).length} Regeln
|
||||
</p>
|
||||
</div>
|
||||
<div className="divide-y divide-slate-100">
|
||||
{rules.filter(r => r.category === category).map(rule => (
|
||||
<div key={rule.code} className="p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-sm text-slate-500">{rule.code}</span>
|
||||
<span className={`text-xs px-2 py-0.5 rounded ${
|
||||
rule.severity === 'BLOCK' ? 'bg-red-100 text-red-700' :
|
||||
rule.severity === 'WARN' ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-blue-100 text-blue-700'
|
||||
}`}>
|
||||
{rule.severity}
|
||||
</span>
|
||||
</div>
|
||||
<div className="font-medium text-slate-800 mt-1">{rule.title}</div>
|
||||
<div className="text-sm text-slate-600 mt-1">{rule.description}</div>
|
||||
{rule.gdpr_ref && (
|
||||
<div className="text-xs text-slate-500 mt-2">{rule.gdpr_ref}</div>
|
||||
)}
|
||||
</div>
|
||||
{rule.risk_add && (
|
||||
<div className="text-sm font-medium text-red-600">
|
||||
+{rule.risk_add}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
const renderLegalCorpus = () => (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="font-semibold text-slate-800 text-lg mb-4">Legal RAG Corpus</h3>
|
||||
<p className="text-slate-600 mb-4">
|
||||
Das System verwendet einen semantischen Suchindex mit 2.274 Chunks aus 19 EU-Regulierungen
|
||||
fuer rechtsgrundlagenbasierte Erklaerungen.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="p-4 bg-blue-50 rounded-lg border border-blue-200">
|
||||
<h4 className="font-medium text-blue-800 mb-2">Indexierte Regulierungen</h4>
|
||||
<ul className="text-sm text-blue-700 space-y-1">
|
||||
<li>DSGVO - Datenschutz-Grundverordnung</li>
|
||||
<li>AI Act - EU KI-Verordnung</li>
|
||||
<li>NIS2 - Cybersicherheits-Richtlinie</li>
|
||||
<li>CRA - Cyber Resilience Act</li>
|
||||
<li>Data Act - Datengesetz</li>
|
||||
<li>DSA/DMA - Digital Services/Markets Act</li>
|
||||
<li>DPF - EU-US Data Privacy Framework</li>
|
||||
<li>BSI-TR-03161 - Digitale Identitaeten</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="p-4 bg-green-50 rounded-lg border border-green-200">
|
||||
<h4 className="font-medium text-green-800 mb-2">RAG-Funktionalitaet</h4>
|
||||
<ul className="text-sm text-green-700 space-y-1">
|
||||
<li>Hybride Suche (Dense + BM25)</li>
|
||||
<li>Semantisches Chunking</li>
|
||||
<li>Cross-Encoder Reranking</li>
|
||||
<li>Artikel-Referenz-Extraktion</li>
|
||||
<li>Mehrsprachig (DE/EN)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="font-semibold text-slate-800 mb-4">Verwendung im System</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-8 h-8 bg-purple-100 text-purple-700 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
1
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-slate-800">Benutzer fordert Erklaerung an</div>
|
||||
<div className="text-sm text-slate-600">
|
||||
Nach der Bewertung kann eine LLM-basierte Erklaerung generiert werden.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-8 h-8 bg-purple-100 text-purple-700 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
2
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-slate-800">Legal RAG Client sucht relevante Artikel</div>
|
||||
<div className="text-sm text-slate-600">
|
||||
Basierend auf den ausgeloesten Regeln werden passende Gesetzestexte gefunden.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-8 h-8 bg-purple-100 text-purple-700 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
3
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-slate-800">LLM generiert Erklaerung mit Rechtsgrundlage</div>
|
||||
<div className="text-sm text-slate-600">
|
||||
Die Erklaerung referenziert konkrete Artikel aus DSGVO, AI Act etc.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// Tabs Configuration
|
||||
// ============================================================================
|
||||
|
||||
const tabs: { id: DocTab; label: string }[] = [
|
||||
{ id: 'overview', label: 'Uebersicht' },
|
||||
{ id: 'architecture', label: 'Architektur' },
|
||||
{ id: 'auditor', label: 'Fuer Auditoren' },
|
||||
{ id: 'rules', label: 'Regel-Katalog' },
|
||||
{ id: 'legal-corpus', label: 'Legal RAG' },
|
||||
]
|
||||
|
||||
// ============================================================================
|
||||
// Main Render
|
||||
// ============================================================================
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6 flex-1">
|
||||
<h1 className="text-2xl font-bold text-slate-900">UCCA System-Dokumentation</h1>
|
||||
<p className="text-slate-500 mt-1">
|
||||
Transparente Dokumentation des UCCA-Systems fuer Entwickler, Auditoren und Datenschutzbeauftragte.
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/sdk/advisory-board"
|
||||
className="ml-4 px-4 py-2 text-sm text-slate-600 hover:text-slate-800 border border-slate-200 rounded-lg hover:bg-slate-50"
|
||||
>
|
||||
Zurueck zum Advisory Board
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden">
|
||||
<div className="flex border-b border-slate-200 overflow-x-auto">
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium whitespace-nowrap transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'text-purple-600 border-b-2 border-purple-600 bg-purple-50'
|
||||
: 'text-slate-600 hover:text-slate-800 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
{activeTab === 'overview' && renderOverview()}
|
||||
{activeTab === 'architecture' && renderArchitecture()}
|
||||
{activeTab === 'auditor' && renderAuditorInfo()}
|
||||
{activeTab === 'rules' && renderRulesTab()}
|
||||
{activeTab === 'legal-corpus' && renderLegalCorpus()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,667 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useSDK, UseCaseAssessment } from '@/lib/sdk'
|
||||
|
||||
// =============================================================================
|
||||
// WIZARD STEPS
|
||||
// =============================================================================
|
||||
|
||||
const WIZARD_STEPS = [
|
||||
{ id: 1, name: 'Grunddaten', description: 'Name und Beschreibung des Use Cases' },
|
||||
{ id: 2, name: 'Datenkategorien', description: 'Welche Daten werden verarbeitet?' },
|
||||
{ id: 3, name: 'Technologie', description: 'Eingesetzte KI-Technologien' },
|
||||
{ id: 4, name: 'Risikobewertung', description: 'Erste Risikoeinschätzung' },
|
||||
{ id: 5, name: 'Zusammenfassung', description: 'Überprüfung und Abschluss' },
|
||||
]
|
||||
|
||||
// =============================================================================
|
||||
// USE CASE CARD
|
||||
// =============================================================================
|
||||
|
||||
function UseCaseCard({
|
||||
useCase,
|
||||
isActive,
|
||||
onSelect,
|
||||
onDelete,
|
||||
}: {
|
||||
useCase: UseCaseAssessment
|
||||
isActive: boolean
|
||||
onSelect: () => void
|
||||
onDelete: () => void
|
||||
}) {
|
||||
const completionPercent = Math.round((useCase.stepsCompleted / 5) * 100)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative bg-white rounded-xl border-2 p-6 transition-all cursor-pointer ${
|
||||
isActive ? 'border-purple-500 shadow-lg' : 'border-gray-200 hover:border-purple-300'
|
||||
}`}
|
||||
onClick={onSelect}
|
||||
>
|
||||
{/* Delete Button */}
|
||||
<button
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
onDelete()
|
||||
}}
|
||||
className="absolute top-4 right-4 p-1 text-gray-400 hover:text-red-500 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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div className="flex items-start gap-4">
|
||||
<div
|
||||
className={`w-12 h-12 rounded-xl flex items-center justify-center ${
|
||||
completionPercent === 100
|
||||
? 'bg-green-100 text-green-600'
|
||||
: 'bg-purple-100 text-purple-600'
|
||||
}`}
|
||||
>
|
||||
{completionPercent === 100 ? (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-6 h-6" 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 2" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-lg font-semibold text-gray-900 truncate">{useCase.name}</h3>
|
||||
<p className="text-sm text-gray-500 line-clamp-2">{useCase.description}</p>
|
||||
<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">{completionPercent}%</span>
|
||||
</div>
|
||||
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${
|
||||
completionPercent === 100 ? 'bg-green-500' : 'bg-purple-600'
|
||||
}`}
|
||||
style={{ width: `${completionPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{useCase.assessmentResult && (
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<span
|
||||
className={`px-2 py-1 text-xs rounded-full ${
|
||||
useCase.assessmentResult.riskLevel === 'CRITICAL'
|
||||
? 'bg-red-100 text-red-700'
|
||||
: useCase.assessmentResult.riskLevel === 'HIGH'
|
||||
? 'bg-orange-100 text-orange-700'
|
||||
: useCase.assessmentResult.riskLevel === 'MEDIUM'
|
||||
? 'bg-yellow-100 text-yellow-700'
|
||||
: 'bg-green-100 text-green-700'
|
||||
}`}
|
||||
>
|
||||
Risiko: {useCase.assessmentResult.riskLevel}
|
||||
</span>
|
||||
{useCase.assessmentResult.dsfaRequired && (
|
||||
<span className="px-2 py-1 text-xs bg-purple-100 text-purple-700 rounded-full">
|
||||
DSFA erforderlich
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// WIZARD
|
||||
// =============================================================================
|
||||
|
||||
interface WizardFormData {
|
||||
name: string
|
||||
description: string
|
||||
category: string
|
||||
dataCategories: string[]
|
||||
processesPersonalData: boolean
|
||||
specialCategories: boolean
|
||||
aiTechnologies: string[]
|
||||
dataVolume: string
|
||||
riskLevel: string
|
||||
notes: string
|
||||
}
|
||||
|
||||
function UseCaseWizard({
|
||||
onComplete,
|
||||
onCancel,
|
||||
}: {
|
||||
onComplete: (useCase: UseCaseAssessment) => void
|
||||
onCancel: () => void
|
||||
}) {
|
||||
const [currentStep, setCurrentStep] = useState(1)
|
||||
const [formData, setFormData] = useState<WizardFormData>({
|
||||
name: '',
|
||||
description: '',
|
||||
category: '',
|
||||
dataCategories: [],
|
||||
processesPersonalData: false,
|
||||
specialCategories: false,
|
||||
aiTechnologies: [],
|
||||
dataVolume: 'medium',
|
||||
riskLevel: 'medium',
|
||||
notes: '',
|
||||
})
|
||||
|
||||
const updateFormData = (updates: Partial<WizardFormData>) => {
|
||||
setFormData(prev => ({ ...prev, ...updates }))
|
||||
}
|
||||
|
||||
const handleNext = () => {
|
||||
if (currentStep < 5) {
|
||||
setCurrentStep(prev => prev + 1)
|
||||
} else {
|
||||
// Create use case
|
||||
const newUseCase: UseCaseAssessment = {
|
||||
id: `uc-${Date.now()}`,
|
||||
name: formData.name,
|
||||
description: formData.description,
|
||||
category: formData.category,
|
||||
stepsCompleted: 5,
|
||||
steps: WIZARD_STEPS.map(s => ({
|
||||
id: `step-${s.id}`,
|
||||
name: s.name,
|
||||
completed: true,
|
||||
data: {},
|
||||
})),
|
||||
assessmentResult: {
|
||||
riskLevel: formData.riskLevel as 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL',
|
||||
applicableRegulations: ['DSGVO', 'AI Act'],
|
||||
recommendedControls: ['Datenschutz-Folgenabschätzung', 'Technische Maßnahmen'],
|
||||
dsfaRequired: formData.specialCategories || formData.riskLevel === 'HIGH',
|
||||
aiActClassification: formData.aiTechnologies.length > 0 ? 'LIMITED' : 'MINIMAL',
|
||||
},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
onComplete(newUseCase)
|
||||
}
|
||||
}
|
||||
|
||||
const handleBack = () => {
|
||||
if (currentStep > 1) {
|
||||
setCurrentStep(prev => prev - 1)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b border-gray-200 bg-gray-50">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Neuer Use Case</h2>
|
||||
<button onClick={onCancel} className="text-gray-400 hover:text-gray-600">
|
||||
<svg className="w-6 h-6" 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>
|
||||
{/* Progress */}
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
{WIZARD_STEPS.map((step, index) => (
|
||||
<React.Fragment key={step.id}>
|
||||
<div
|
||||
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
|
||||
step.id < currentStep
|
||||
? 'bg-green-500 text-white'
|
||||
: step.id === currentStep
|
||||
? 'bg-purple-600 text-white'
|
||||
: 'bg-gray-200 text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{step.id < currentStep ? (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : (
|
||||
step.id
|
||||
)}
|
||||
</div>
|
||||
{index < WIZARD_STEPS.length - 1 && (
|
||||
<div
|
||||
className={`flex-1 h-1 rounded ${
|
||||
step.id < currentStep ? 'bg-green-500' : 'bg-gray-200'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-gray-500">
|
||||
Schritt {currentStep}: {WIZARD_STEPS[currentStep - 1].description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6">
|
||||
{currentStep === 1 && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Name des Use Cases *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={e => updateFormData({ name: e.target.value })}
|
||||
placeholder="z.B. Marketing-KI für Kundensegmentierung"
|
||||
className="w-full px-4 py-2 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-1">Beschreibung *</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={e => updateFormData({ description: e.target.value })}
|
||||
placeholder="Beschreiben Sie den Anwendungsfall und den Geschäftszweck..."
|
||||
rows={4}
|
||||
className="w-full px-4 py-2 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-1">Kategorie</label>
|
||||
<select
|
||||
value={formData.category}
|
||||
onChange={e => updateFormData({ category: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">Kategorie wählen...</option>
|
||||
<option value="marketing">Marketing & Vertrieb</option>
|
||||
<option value="hr">Personal & HR</option>
|
||||
<option value="finance">Finanzen & Controlling</option>
|
||||
<option value="operations">Betrieb & Produktion</option>
|
||||
<option value="customer">Kundenservice</option>
|
||||
<option value="other">Sonstiges</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep === 2 && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Werden personenbezogene Daten verarbeitet?
|
||||
</label>
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
checked={formData.processesPersonalData}
|
||||
onChange={() => updateFormData({ processesPersonalData: true })}
|
||||
className="w-4 h-4 text-purple-600"
|
||||
/>
|
||||
<span>Ja</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
checked={!formData.processesPersonalData}
|
||||
onChange={() => updateFormData({ processesPersonalData: false })}
|
||||
className="w-4 h-4 text-purple-600"
|
||||
/>
|
||||
<span>Nein</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{formData.processesPersonalData && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Welche Datenkategorien? (Mehrfachauswahl)
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{['Name/Kontakt', 'E-Mail', 'Adresse', 'Telefon', 'Geburtsdatum', 'Finanzdaten', 'Standort', 'Nutzungsverhalten'].map(
|
||||
cat => (
|
||||
<label key={cat} className="flex items-center gap-2 p-2 border rounded-lg hover:bg-gray-50">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.dataCategories.includes(cat)}
|
||||
onChange={e => {
|
||||
if (e.target.checked) {
|
||||
updateFormData({ dataCategories: [...formData.dataCategories, cat] })
|
||||
} else {
|
||||
updateFormData({
|
||||
dataCategories: formData.dataCategories.filter(c => c !== cat),
|
||||
})
|
||||
}
|
||||
}}
|
||||
className="w-4 h-4 text-purple-600"
|
||||
/>
|
||||
<span className="text-sm">{cat}</span>
|
||||
</label>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.specialCategories}
|
||||
onChange={e => updateFormData({ specialCategories: e.target.checked })}
|
||||
className="w-4 h-4 text-purple-600"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
Besondere Kategorien (Art. 9 DSGVO): Gesundheit, Biometrie, Religion, etc.
|
||||
</span>
|
||||
</label>
|
||||
{formData.specialCategories && (
|
||||
<p className="mt-2 text-sm text-amber-600 bg-amber-50 p-3 rounded-lg">
|
||||
Bei besonderen Kategorien ist eine DSFA in der Regel erforderlich!
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep === 3 && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Eingesetzte KI-Technologien (Mehrfachauswahl)
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{[
|
||||
'Machine Learning',
|
||||
'Deep Learning',
|
||||
'Natural Language Processing',
|
||||
'Computer Vision',
|
||||
'Generative AI (LLM)',
|
||||
'Empfehlungssysteme',
|
||||
'Predictive Analytics',
|
||||
'Chatbots/Assistenten',
|
||||
].map(tech => (
|
||||
<label key={tech} className="flex items-center gap-2 p-2 border rounded-lg hover:bg-gray-50">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.aiTechnologies.includes(tech)}
|
||||
onChange={e => {
|
||||
if (e.target.checked) {
|
||||
updateFormData({ aiTechnologies: [...formData.aiTechnologies, tech] })
|
||||
} else {
|
||||
updateFormData({
|
||||
aiTechnologies: formData.aiTechnologies.filter(t => t !== tech),
|
||||
})
|
||||
}
|
||||
}}
|
||||
className="w-4 h-4 text-purple-600"
|
||||
/>
|
||||
<span className="text-sm">{tech}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Erwartetes Datenvolumen</label>
|
||||
<select
|
||||
value={formData.dataVolume}
|
||||
onChange={e => updateFormData({ dataVolume: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
>
|
||||
<option value="small">Klein (< 1.000 Datensätze)</option>
|
||||
<option value="medium">Mittel (1.000 - 100.000 Datensätze)</option>
|
||||
<option value="large">Groß (100.000 - 1 Mio. Datensätze)</option>
|
||||
<option value="xlarge">Sehr groß (> 1 Mio. Datensätze)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep === 4 && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Erste Risikoeinschätzung
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
{ value: 'low', label: 'Niedrig', description: 'Keine personenbezogenen Daten, kein kritischer Einsatz' },
|
||||
{ value: 'medium', label: 'Mittel', description: 'Personenbezogene Daten, aber kein kritischer Einsatz' },
|
||||
{ value: 'high', label: 'Hoch', description: 'Besondere Kategorien oder automatisierte Entscheidungen' },
|
||||
{ value: 'critical', label: 'Kritisch', description: 'Hochrisiko-KI nach AI Act' },
|
||||
].map(option => (
|
||||
<label
|
||||
key={option.value}
|
||||
className={`flex items-start gap-3 p-4 border rounded-lg cursor-pointer transition-colors ${
|
||||
formData.riskLevel === option.value
|
||||
? 'border-purple-500 bg-purple-50'
|
||||
: 'hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
checked={formData.riskLevel === option.value}
|
||||
onChange={() => updateFormData({ riskLevel: option.value })}
|
||||
className="mt-1 w-4 h-4 text-purple-600"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium">{option.label}</div>
|
||||
<div className="text-sm text-gray-500">{option.description}</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Notizen</label>
|
||||
<textarea
|
||||
value={formData.notes}
|
||||
onChange={e => updateFormData({ notes: e.target.value })}
|
||||
placeholder="Zusätzliche Anmerkungen zur Risikobewertung..."
|
||||
rows={3}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep === 5 && (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-gray-50 rounded-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Zusammenfassung</h3>
|
||||
<dl className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-gray-500">Name:</dt>
|
||||
<dd className="font-medium text-gray-900">{formData.name || '-'}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-gray-500">Kategorie:</dt>
|
||||
<dd className="font-medium text-gray-900">{formData.category || '-'}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-gray-500">Personenbezogene Daten:</dt>
|
||||
<dd className="font-medium text-gray-900">
|
||||
{formData.processesPersonalData ? 'Ja' : 'Nein'}
|
||||
</dd>
|
||||
</div>
|
||||
{formData.processesPersonalData && (
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-gray-500">Datenkategorien:</dt>
|
||||
<dd className="font-medium text-gray-900">{formData.dataCategories.join(', ') || '-'}</dd>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-gray-500">KI-Technologien:</dt>
|
||||
<dd className="font-medium text-gray-900">{formData.aiTechnologies.join(', ') || '-'}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-gray-500">Risikostufe:</dt>
|
||||
<dd
|
||||
className={`font-medium px-2 py-0.5 rounded ${
|
||||
formData.riskLevel === 'critical'
|
||||
? 'bg-red-100 text-red-700'
|
||||
: formData.riskLevel === 'high'
|
||||
? 'bg-orange-100 text-orange-700'
|
||||
: formData.riskLevel === 'medium'
|
||||
? 'bg-yellow-100 text-yellow-700'
|
||||
: 'bg-green-100 text-green-700'
|
||||
}`}
|
||||
>
|
||||
{formData.riskLevel.toUpperCase()}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{(formData.specialCategories || formData.riskLevel === 'high' || formData.riskLevel === 'critical') && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<svg
|
||||
className="w-5 h-5 text-amber-600 mt-0.5"
|
||||
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>
|
||||
<h4 className="font-medium text-amber-800">DSFA erforderlich</h4>
|
||||
<p className="text-sm text-amber-700 mt-1">
|
||||
Basierend auf Ihrer Eingabe wird eine Datenschutz-Folgenabschätzung empfohlen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-4 border-t border-gray-200 bg-gray-50 flex items-center justify-between">
|
||||
<button
|
||||
onClick={currentStep === 1 ? onCancel : handleBack}
|
||||
className="px-4 py-2 text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
{currentStep === 1 ? 'Abbrechen' : 'Zurück'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleNext}
|
||||
disabled={currentStep === 1 && !formData.name}
|
||||
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
|
||||
currentStep === 1 && !formData.name
|
||||
? 'bg-gray-200 text-gray-400 cursor-not-allowed'
|
||||
: 'bg-purple-600 text-white hover:bg-purple-700'
|
||||
}`}
|
||||
>
|
||||
{currentStep === 5 ? 'Abschließen' : 'Weiter'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN PAGE
|
||||
// =============================================================================
|
||||
|
||||
export default function AdvisoryBoardPage() {
|
||||
const { state, dispatch } = useSDK()
|
||||
const [showWizard, setShowWizard] = useState(false)
|
||||
|
||||
const handleCreateUseCase = (useCase: UseCaseAssessment) => {
|
||||
dispatch({ type: 'ADD_USE_CASE', payload: useCase })
|
||||
dispatch({ type: 'SET_ACTIVE_USE_CASE', payload: useCase.id })
|
||||
setShowWizard(false)
|
||||
}
|
||||
|
||||
const handleDeleteUseCase = (id: string) => {
|
||||
if (confirm('Möchten Sie diesen Use Case wirklich löschen?')) {
|
||||
dispatch({ type: 'DELETE_USE_CASE', payload: id })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Use Case Workshop</h1>
|
||||
<p className="mt-1 text-gray-500">
|
||||
Erfassen Sie Ihre KI-Anwendungsfälle und erhalten Sie eine erste Compliance-Bewertung
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
href="/sdk/advisory-board/documentation"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 text-sm text-purple-600 hover:text-purple-700 hover:bg-purple-50 border border-purple-300 rounded-lg transition-colors"
|
||||
>
|
||||
UCCA-System Dokumentation ansehen
|
||||
</Link>
|
||||
{!showWizard && (
|
||||
<button
|
||||
onClick={() => setShowWizard(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 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
Neuer Use Case
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Wizard or List */}
|
||||
{showWizard ? (
|
||||
<UseCaseWizard onComplete={handleCreateUseCase} onCancel={() => setShowWizard(false)} />
|
||||
) : state.useCases.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-purple-100 rounded-full flex items-center justify-center mb-4">
|
||||
<svg className="w-8 h-8 text-purple-600" 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 2"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Noch keine Use Cases</h3>
|
||||
<p className="mt-2 text-gray-500 max-w-md mx-auto">
|
||||
Erstellen Sie Ihren ersten Use Case, um mit dem Compliance Assessment zu beginnen.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowWizard(true)}
|
||||
className="mt-6 px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
Ersten Use Case erstellen
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{state.useCases.map(useCase => (
|
||||
<UseCaseCard
|
||||
key={useCase.id}
|
||||
useCase={useCase}
|
||||
isActive={state.activeUseCase === useCase.id}
|
||||
onSelect={() => dispatch({ type: 'SET_ACTIVE_USE_CASE', payload: useCase.id })}
|
||||
onDelete={() => handleDeleteUseCase(useCase.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,295 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { useSDK } from '@/lib/sdk'
|
||||
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
interface AISystem {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
classification: 'prohibited' | 'high-risk' | 'limited-risk' | 'minimal-risk' | 'unclassified'
|
||||
purpose: string
|
||||
sector: string
|
||||
status: 'draft' | 'classified' | 'compliant' | 'non-compliant'
|
||||
obligations: string[]
|
||||
assessmentDate: Date | null
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MOCK DATA
|
||||
// =============================================================================
|
||||
|
||||
const mockAISystems: AISystem[] = [
|
||||
{
|
||||
id: 'ai-1',
|
||||
name: 'Kundenservice Chatbot',
|
||||
description: 'KI-gestuetzter Chatbot fuer Kundenanfragen',
|
||||
classification: 'limited-risk',
|
||||
purpose: 'Automatisierte Beantwortung von Kundenanfragen',
|
||||
sector: 'Kundenservice',
|
||||
status: 'classified',
|
||||
obligations: ['Transparenzpflicht', 'Kennzeichnung als KI-System'],
|
||||
assessmentDate: new Date('2024-01-15'),
|
||||
},
|
||||
{
|
||||
id: 'ai-2',
|
||||
name: 'Bewerber-Screening',
|
||||
description: 'KI-System zur Vorauswahl von Bewerbungen',
|
||||
classification: 'high-risk',
|
||||
purpose: 'Automatisierte Bewertung von Bewerbungsunterlagen',
|
||||
sector: 'Personal',
|
||||
status: 'non-compliant',
|
||||
obligations: ['Risikomanagementsystem', 'Datenlenkung', 'Technische Dokumentation', 'Menschliche Aufsicht', 'Transparenz'],
|
||||
assessmentDate: new Date('2024-01-10'),
|
||||
},
|
||||
{
|
||||
id: 'ai-3',
|
||||
name: 'Empfehlungsalgorithmus',
|
||||
description: 'Personalisierte Produktempfehlungen',
|
||||
classification: 'minimal-risk',
|
||||
purpose: 'Verbesserung der Kundenerfahrung durch personalisierte Empfehlungen',
|
||||
sector: 'E-Commerce',
|
||||
status: 'compliant',
|
||||
obligations: [],
|
||||
assessmentDate: new Date('2024-01-05'),
|
||||
},
|
||||
{
|
||||
id: 'ai-4',
|
||||
name: 'Neue KI-Anwendung',
|
||||
description: 'Noch nicht klassifiziertes System',
|
||||
classification: 'unclassified',
|
||||
purpose: 'In Evaluierung',
|
||||
sector: 'Unbestimmt',
|
||||
status: 'draft',
|
||||
obligations: [],
|
||||
assessmentDate: null,
|
||||
},
|
||||
]
|
||||
|
||||
// =============================================================================
|
||||
// COMPONENTS
|
||||
// =============================================================================
|
||||
|
||||
function RiskPyramid({ systems }: { systems: AISystem[] }) {
|
||||
const counts = {
|
||||
prohibited: systems.filter(s => s.classification === 'prohibited').length,
|
||||
'high-risk': systems.filter(s => s.classification === 'high-risk').length,
|
||||
'limited-risk': systems.filter(s => s.classification === 'limited-risk').length,
|
||||
'minimal-risk': systems.filter(s => s.classification === 'minimal-risk').length,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">AI Act Risikopyramide</h3>
|
||||
<div className="flex flex-col items-center space-y-1">
|
||||
<div className="w-24 h-12 bg-red-500 text-white flex items-center justify-center rounded-t-lg text-sm font-medium">
|
||||
Verboten ({counts.prohibited})
|
||||
</div>
|
||||
<div className="w-40 h-12 bg-orange-500 text-white flex items-center justify-center text-sm font-medium">
|
||||
Hochrisiko ({counts['high-risk']})
|
||||
</div>
|
||||
<div className="w-56 h-12 bg-yellow-500 text-white flex items-center justify-center text-sm font-medium">
|
||||
Begrenztes Risiko ({counts['limited-risk']})
|
||||
</div>
|
||||
<div className="w-72 h-12 bg-green-500 text-white flex items-center justify-center rounded-b-lg text-sm font-medium">
|
||||
Minimales Risiko ({counts['minimal-risk']})
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 text-center text-sm text-gray-500">
|
||||
{systems.filter(s => s.classification === 'unclassified').length} System(e) noch nicht klassifiziert
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AISystemCard({ system }: { system: AISystem }) {
|
||||
const classificationColors = {
|
||||
prohibited: 'bg-red-100 text-red-700 border-red-200',
|
||||
'high-risk': 'bg-orange-100 text-orange-700 border-orange-200',
|
||||
'limited-risk': 'bg-yellow-100 text-yellow-700 border-yellow-200',
|
||||
'minimal-risk': 'bg-green-100 text-green-700 border-green-200',
|
||||
unclassified: 'bg-gray-100 text-gray-500 border-gray-200',
|
||||
}
|
||||
|
||||
const classificationLabels = {
|
||||
prohibited: 'Verboten',
|
||||
'high-risk': 'Hochrisiko',
|
||||
'limited-risk': 'Begrenztes Risiko',
|
||||
'minimal-risk': 'Minimales Risiko',
|
||||
unclassified: 'Nicht klassifiziert',
|
||||
}
|
||||
|
||||
const statusColors = {
|
||||
draft: 'bg-gray-100 text-gray-500',
|
||||
classified: 'bg-blue-100 text-blue-700',
|
||||
compliant: 'bg-green-100 text-green-700',
|
||||
'non-compliant': 'bg-red-100 text-red-700',
|
||||
}
|
||||
|
||||
const statusLabels = {
|
||||
draft: 'Entwurf',
|
||||
classified: 'Klassifiziert',
|
||||
compliant: 'Konform',
|
||||
'non-compliant': 'Nicht konform',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-xl border-2 p-6 ${
|
||||
system.classification === 'high-risk' ? 'border-orange-200' :
|
||||
system.classification === 'prohibited' ? 'border-red-200' : 'border-gray-200'
|
||||
}`}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${classificationColors[system.classification]}`}>
|
||||
{classificationLabels[system.classification]}
|
||||
</span>
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${statusColors[system.status]}`}>
|
||||
{statusLabels[system.status]}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">{system.name}</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">{system.description}</p>
|
||||
<div className="mt-2 text-sm text-gray-500">
|
||||
<span>Sektor: {system.sector}</span>
|
||||
{system.assessmentDate && (
|
||||
<span className="ml-4">Klassifiziert: {system.assessmentDate.toLocaleDateString('de-DE')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{system.obligations.length > 0 && (
|
||||
<div className="mt-4 pt-4 border-t border-gray-100">
|
||||
<p className="text-sm font-medium text-gray-700 mb-2">Pflichten nach AI Act:</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{system.obligations.map(obl => (
|
||||
<span key={obl} className="px-2 py-1 text-xs bg-purple-50 text-purple-700 rounded">
|
||||
{obl}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<button className="flex-1 px-4 py-2 text-sm bg-purple-50 text-purple-700 rounded-lg hover:bg-purple-100 transition-colors">
|
||||
{system.classification === 'unclassified' ? 'Klassifizierung starten' : 'Details anzeigen'}
|
||||
</button>
|
||||
<button className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
Bearbeiten
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN PAGE
|
||||
// =============================================================================
|
||||
|
||||
export default function AIActPage() {
|
||||
const { state } = useSDK()
|
||||
const [systems] = useState<AISystem[]>(mockAISystems)
|
||||
const [filter, setFilter] = useState<string>('all')
|
||||
|
||||
const filteredSystems = filter === 'all'
|
||||
? systems
|
||||
: systems.filter(s => s.classification === filter || s.status === filter)
|
||||
|
||||
const highRiskCount = systems.filter(s => s.classification === 'high-risk').length
|
||||
const compliantCount = systems.filter(s => s.status === 'compliant').length
|
||||
const unclassifiedCount = systems.filter(s => s.classification === 'unclassified').length
|
||||
|
||||
const stepInfo = STEP_EXPLANATIONS['ai-act']
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Step Header */}
|
||||
<StepHeader
|
||||
stepId="ai-act"
|
||||
title={stepInfo.title}
|
||||
description={stepInfo.description}
|
||||
explanation={stepInfo.explanation}
|
||||
tips={stepInfo.tips}
|
||||
>
|
||||
<button 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>
|
||||
KI-System registrieren
|
||||
</button>
|
||||
</StepHeader>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="text-sm text-gray-500">KI-Systeme gesamt</div>
|
||||
<div className="text-3xl font-bold text-gray-900">{systems.length}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-orange-200 p-6">
|
||||
<div className="text-sm text-orange-600">Hochrisiko</div>
|
||||
<div className="text-3xl font-bold text-orange-600">{highRiskCount}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-green-200 p-6">
|
||||
<div className="text-sm text-green-600">Konform</div>
|
||||
<div className="text-3xl font-bold text-green-600">{compliantCount}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="text-sm text-gray-500">Nicht klassifiziert</div>
|
||||
<div className="text-3xl font-bold text-gray-500">{unclassifiedCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Risk Pyramid */}
|
||||
<RiskPyramid systems={systems} />
|
||||
|
||||
{/* Filter */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm text-gray-500">Filter:</span>
|
||||
{['all', 'high-risk', 'limited-risk', 'minimal-risk', 'unclassified', 'compliant', 'non-compliant'].map(f => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
className={`px-3 py-1 text-sm rounded-full transition-colors ${
|
||||
filter === f
|
||||
? 'bg-purple-600 text-white'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{f === 'all' ? 'Alle' :
|
||||
f === 'high-risk' ? 'Hochrisiko' :
|
||||
f === 'limited-risk' ? 'Begrenztes Risiko' :
|
||||
f === 'minimal-risk' ? 'Minimales Risiko' :
|
||||
f === 'unclassified' ? 'Nicht klassifiziert' :
|
||||
f === 'compliant' ? 'Konform' : 'Nicht konform'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* AI Systems List */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{filteredSystems.map(system => (
|
||||
<AISystemCard key={system.id} system={system} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredSystems.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-purple-100 rounded-full flex items-center justify-center mb-4">
|
||||
<svg className="w-8 h-8 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Keine KI-Systeme gefunden</h3>
|
||||
<p className="mt-2 text-gray-500">Passen Sie den Filter an oder registrieren Sie ein neues KI-System.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,481 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useSDK, ChecklistItem as SDKChecklistItem } from '@/lib/sdk'
|
||||
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
type DisplayStatus = 'compliant' | 'non-compliant' | 'partial' | 'not-reviewed'
|
||||
type DisplayPriority = 'critical' | 'high' | 'medium' | 'low'
|
||||
|
||||
interface DisplayChecklistItem {
|
||||
id: string
|
||||
requirementId: string
|
||||
question: string
|
||||
category: string
|
||||
status: DisplayStatus
|
||||
notes: string
|
||||
evidence: string[]
|
||||
priority: DisplayPriority
|
||||
verifiedBy: string | null
|
||||
verifiedAt: Date | null
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// =============================================================================
|
||||
|
||||
function mapSDKStatusToDisplay(status: SDKChecklistItem['status']): DisplayStatus {
|
||||
switch (status) {
|
||||
case 'PASSED': return 'compliant'
|
||||
case 'FAILED': return 'non-compliant'
|
||||
case 'NOT_APPLICABLE': return 'partial'
|
||||
case 'PENDING':
|
||||
default: return 'not-reviewed'
|
||||
}
|
||||
}
|
||||
|
||||
function mapDisplayStatusToSDK(status: DisplayStatus): SDKChecklistItem['status'] {
|
||||
switch (status) {
|
||||
case 'compliant': return 'PASSED'
|
||||
case 'non-compliant': return 'FAILED'
|
||||
case 'partial': return 'NOT_APPLICABLE'
|
||||
case 'not-reviewed':
|
||||
default: return 'PENDING'
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CHECKLIST TEMPLATES
|
||||
// =============================================================================
|
||||
|
||||
interface ChecklistTemplate {
|
||||
id: string
|
||||
requirementId: string
|
||||
question: string
|
||||
category: string
|
||||
priority: DisplayPriority
|
||||
}
|
||||
|
||||
const checklistTemplates: ChecklistTemplate[] = [
|
||||
{
|
||||
id: 'chk-vvt-001',
|
||||
requirementId: 'req-gdpr-30',
|
||||
question: 'Ist ein Verzeichnis von Verarbeitungstaetigkeiten (VVT) vorhanden und aktuell?',
|
||||
category: 'Dokumentation',
|
||||
priority: 'critical',
|
||||
},
|
||||
{
|
||||
id: 'chk-dse-001',
|
||||
requirementId: 'req-gdpr-13',
|
||||
question: 'Sind Datenschutzhinweise fuer alle Verarbeitungen verfuegbar?',
|
||||
category: 'Transparenz',
|
||||
priority: 'high',
|
||||
},
|
||||
{
|
||||
id: 'chk-consent-001',
|
||||
requirementId: 'req-gdpr-6',
|
||||
question: 'Werden Einwilligungen ordnungsgemaess eingeholt und dokumentiert?',
|
||||
category: 'Einwilligung',
|
||||
priority: 'high',
|
||||
},
|
||||
{
|
||||
id: 'chk-dsr-001',
|
||||
requirementId: 'req-gdpr-15',
|
||||
question: 'Ist ein Prozess fuer Betroffenenrechte implementiert?',
|
||||
category: 'Betroffenenrechte',
|
||||
priority: 'critical',
|
||||
},
|
||||
{
|
||||
id: 'chk-avv-001',
|
||||
requirementId: 'req-gdpr-28',
|
||||
question: 'Sind Auftragsverarbeitungsvertraege (AVV) mit allen Dienstleistern abgeschlossen?',
|
||||
category: 'Auftragsverarbeitung',
|
||||
priority: 'critical',
|
||||
},
|
||||
{
|
||||
id: 'chk-dsfa-001',
|
||||
requirementId: 'req-gdpr-35',
|
||||
question: 'Wird eine DSFA fuer Hochrisiko-Verarbeitungen durchgefuehrt?',
|
||||
category: 'Risikobewertung',
|
||||
priority: 'high',
|
||||
},
|
||||
{
|
||||
id: 'chk-tom-001',
|
||||
requirementId: 'req-gdpr-32',
|
||||
question: 'Sind technische und organisatorische Massnahmen dokumentiert?',
|
||||
category: 'TOMs',
|
||||
priority: 'high',
|
||||
},
|
||||
{
|
||||
id: 'chk-incident-001',
|
||||
requirementId: 'req-gdpr-33',
|
||||
question: 'Gibt es einen Incident-Response-Prozess fuer Datenpannen?',
|
||||
category: 'Incident Response',
|
||||
priority: 'critical',
|
||||
},
|
||||
{
|
||||
id: 'chk-ai-001',
|
||||
requirementId: 'req-ai-act-9',
|
||||
question: 'Ist das KI-System nach EU AI Act klassifiziert?',
|
||||
category: 'AI Act',
|
||||
priority: 'high',
|
||||
},
|
||||
{
|
||||
id: 'chk-ai-002',
|
||||
requirementId: 'req-ai-act-13',
|
||||
question: 'Sind Transparenzanforderungen fuer KI-Systeme erfuellt?',
|
||||
category: 'AI Act',
|
||||
priority: 'high',
|
||||
},
|
||||
]
|
||||
|
||||
// =============================================================================
|
||||
// COMPONENTS
|
||||
// =============================================================================
|
||||
|
||||
function ChecklistItemCard({
|
||||
item,
|
||||
onStatusChange,
|
||||
onNotesChange,
|
||||
}: {
|
||||
item: DisplayChecklistItem
|
||||
onStatusChange: (status: DisplayStatus) => void
|
||||
onNotesChange: (notes: string) => void
|
||||
}) {
|
||||
const [showNotes, setShowNotes] = useState(false)
|
||||
|
||||
const statusColors = {
|
||||
compliant: 'bg-green-100 text-green-700 border-green-300',
|
||||
'non-compliant': 'bg-red-100 text-red-700 border-red-300',
|
||||
partial: 'bg-yellow-100 text-yellow-700 border-yellow-300',
|
||||
'not-reviewed': 'bg-gray-100 text-gray-500 border-gray-300',
|
||||
}
|
||||
|
||||
const priorityColors = {
|
||||
critical: 'bg-red-100 text-red-700',
|
||||
high: 'bg-orange-100 text-orange-700',
|
||||
medium: 'bg-yellow-100 text-yellow-700',
|
||||
low: 'bg-green-100 text-green-700',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-xl border-2 p-6 ${
|
||||
item.status === 'non-compliant' ? 'border-red-200' :
|
||||
item.status === 'partial' ? 'border-yellow-200' :
|
||||
item.status === 'compliant' ? 'border-green-200' : 'border-gray-200'
|
||||
}`}>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-xs text-gray-500">{item.category}</span>
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full ${priorityColors[item.priority]}`}>
|
||||
{item.priority === 'critical' ? 'Kritisch' :
|
||||
item.priority === 'high' ? 'Hoch' :
|
||||
item.priority === 'medium' ? 'Mittel' : 'Niedrig'}
|
||||
</span>
|
||||
<span className="px-2 py-0.5 text-xs bg-blue-50 text-blue-600 rounded">
|
||||
{item.requirementId}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-gray-900 font-medium">{item.question}</p>
|
||||
</div>
|
||||
<select
|
||||
value={item.status}
|
||||
onChange={(e) => onStatusChange(e.target.value as DisplayStatus)}
|
||||
className={`px-3 py-2 rounded-lg border text-sm font-medium ${statusColors[item.status]}`}
|
||||
>
|
||||
<option value="not-reviewed">Nicht geprueft</option>
|
||||
<option value="compliant">Konform</option>
|
||||
<option value="partial">Teilweise</option>
|
||||
<option value="non-compliant">Nicht konform</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{item.notes && (
|
||||
<div className="mt-3 p-3 bg-gray-50 rounded-lg text-sm text-gray-600">
|
||||
{item.notes}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.evidence.length > 0 && (
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<span className="text-sm text-gray-500">Nachweise:</span>
|
||||
{item.evidence.map(ev => (
|
||||
<span key={ev} className="px-2 py-1 text-xs bg-blue-50 text-blue-600 rounded">
|
||||
{ev}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.verifiedBy && item.verifiedAt && (
|
||||
<div className="mt-3 text-sm text-gray-500">
|
||||
Geprueft von {item.verifiedBy} am {item.verifiedAt.toLocaleDateString('de-DE')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setShowNotes(!showNotes)}
|
||||
className="text-sm text-purple-600 hover:text-purple-700"
|
||||
>
|
||||
{showNotes ? 'Notizen ausblenden' : 'Notizen bearbeiten'}
|
||||
</button>
|
||||
<button className="text-sm text-gray-500 hover:text-gray-700">
|
||||
Nachweis hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showNotes && (
|
||||
<div className="mt-3">
|
||||
<textarea
|
||||
value={item.notes}
|
||||
onChange={(e) => onNotesChange(e.target.value)}
|
||||
placeholder="Notizen hinzufuegen..."
|
||||
rows={2}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN PAGE
|
||||
// =============================================================================
|
||||
|
||||
export default function AuditChecklistPage() {
|
||||
const { state, dispatch } = useSDK()
|
||||
const [filter, setFilter] = useState<string>('all')
|
||||
|
||||
// Load checklist items based on requirements when requirements exist
|
||||
useEffect(() => {
|
||||
if (state.requirements.length > 0 && state.checklist.length === 0) {
|
||||
// Add relevant checklist items based on requirements
|
||||
const relevantItems = checklistTemplates.filter(t =>
|
||||
state.requirements.some(r => r.id === t.requirementId)
|
||||
)
|
||||
|
||||
relevantItems.forEach(template => {
|
||||
const sdkItem: SDKChecklistItem = {
|
||||
id: template.id,
|
||||
requirementId: template.requirementId,
|
||||
title: template.question,
|
||||
description: template.category,
|
||||
status: 'PENDING',
|
||||
notes: '',
|
||||
verifiedBy: null,
|
||||
verifiedAt: null,
|
||||
}
|
||||
dispatch({ type: 'SET_STATE', payload: { checklist: [...state.checklist, sdkItem] } })
|
||||
})
|
||||
|
||||
// If no requirements match, add all templates
|
||||
if (relevantItems.length === 0) {
|
||||
checklistTemplates.forEach(template => {
|
||||
const sdkItem: SDKChecklistItem = {
|
||||
id: template.id,
|
||||
requirementId: template.requirementId,
|
||||
title: template.question,
|
||||
description: template.category,
|
||||
status: 'PENDING',
|
||||
notes: '',
|
||||
verifiedBy: null,
|
||||
verifiedAt: null,
|
||||
}
|
||||
dispatch({ type: 'SET_STATE', payload: { checklist: [...state.checklist, sdkItem] } })
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [state.requirements, state.checklist.length, dispatch])
|
||||
|
||||
// Convert SDK checklist items to display items
|
||||
const displayItems: DisplayChecklistItem[] = state.checklist.map(item => {
|
||||
const template = checklistTemplates.find(t => t.id === item.id)
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
requirementId: item.requirementId,
|
||||
question: item.title,
|
||||
category: item.description || template?.category || 'Allgemein',
|
||||
status: mapSDKStatusToDisplay(item.status),
|
||||
notes: item.notes,
|
||||
evidence: [], // Evidence is tracked separately in SDK
|
||||
priority: template?.priority || 'medium',
|
||||
verifiedBy: item.verifiedBy,
|
||||
verifiedAt: item.verifiedAt,
|
||||
}
|
||||
})
|
||||
|
||||
const filteredItems = filter === 'all'
|
||||
? displayItems
|
||||
: displayItems.filter(item => item.status === filter || item.category === filter)
|
||||
|
||||
const compliantCount = displayItems.filter(i => i.status === 'compliant').length
|
||||
const nonCompliantCount = displayItems.filter(i => i.status === 'non-compliant').length
|
||||
const partialCount = displayItems.filter(i => i.status === 'partial').length
|
||||
const notReviewedCount = displayItems.filter(i => i.status === 'not-reviewed').length
|
||||
|
||||
const progress = displayItems.length > 0
|
||||
? Math.round(((compliantCount + partialCount * 0.5) / displayItems.length) * 100)
|
||||
: 0
|
||||
|
||||
const handleStatusChange = (itemId: string, status: DisplayStatus) => {
|
||||
const updatedChecklist = state.checklist.map(item =>
|
||||
item.id === itemId
|
||||
? {
|
||||
...item,
|
||||
status: mapDisplayStatusToSDK(status),
|
||||
verifiedBy: status !== 'not-reviewed' ? 'Aktueller Benutzer' : null,
|
||||
verifiedAt: status !== 'not-reviewed' ? new Date() : null,
|
||||
}
|
||||
: item
|
||||
)
|
||||
dispatch({ type: 'SET_STATE', payload: { checklist: updatedChecklist } })
|
||||
}
|
||||
|
||||
const handleNotesChange = (itemId: string, notes: string) => {
|
||||
const updatedChecklist = state.checklist.map(item =>
|
||||
item.id === itemId ? { ...item, notes } : item
|
||||
)
|
||||
dispatch({ type: 'SET_STATE', payload: { checklist: updatedChecklist } })
|
||||
}
|
||||
|
||||
const stepInfo = STEP_EXPLANATIONS['audit-checklist']
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Step Header */}
|
||||
<StepHeader
|
||||
stepId="audit-checklist"
|
||||
title={stepInfo.title}
|
||||
description={stepInfo.description}
|
||||
explanation={stepInfo.explanation}
|
||||
tips={stepInfo.tips}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
Exportieren
|
||||
</button>
|
||||
<button 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>
|
||||
Neue Checkliste
|
||||
</button>
|
||||
</div>
|
||||
</StepHeader>
|
||||
|
||||
{/* Requirements Alert */}
|
||||
{state.requirements.length === 0 && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<svg className="w-5 h-5 text-amber-600 mt-0.5" 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>
|
||||
<h4 className="font-medium text-amber-800">Keine Anforderungen definiert</h4>
|
||||
<p className="text-sm text-amber-700 mt-1">
|
||||
Bitte definieren Sie zuerst Anforderungen, um die zugehoerige Checkliste zu generieren.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Checklist Info */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900">Compliance Audit {new Date().getFullYear()}</h2>
|
||||
<p className="text-sm text-gray-500 mt-1">Jaehrliche Ueberpruefung der Compliance-Konformitaet</p>
|
||||
<div className="flex items-center gap-4 mt-2 text-sm text-gray-500">
|
||||
<span>Frameworks: DSGVO, AI Act</span>
|
||||
<span>Letzte Aktualisierung: {new Date().toLocaleDateString('de-DE')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-4xl font-bold text-purple-600">{progress}%</div>
|
||||
<div className="text-sm text-gray-500">Fortschritt</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 h-3 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-purple-600 rounded-full transition-all"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-xl border border-green-200 p-4">
|
||||
<div className="text-sm text-green-600">Konform</div>
|
||||
<div className="text-2xl font-bold text-green-600">{compliantCount}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-yellow-200 p-4">
|
||||
<div className="text-sm text-yellow-600">Teilweise</div>
|
||||
<div className="text-2xl font-bold text-yellow-600">{partialCount}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-red-200 p-4">
|
||||
<div className="text-sm text-red-600">Nicht konform</div>
|
||||
<div className="text-2xl font-bold text-red-600">{nonCompliantCount}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-4">
|
||||
<div className="text-sm text-gray-500">Nicht geprueft</div>
|
||||
<div className="text-2xl font-bold text-gray-500">{notReviewedCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm text-gray-500">Filter:</span>
|
||||
{['all', 'not-reviewed', 'non-compliant', 'partial', 'compliant'].map(f => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
className={`px-3 py-1 text-sm rounded-full transition-colors ${
|
||||
filter === f
|
||||
? 'bg-purple-600 text-white'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{f === 'all' ? 'Alle' :
|
||||
f === 'not-reviewed' ? 'Offen' :
|
||||
f === 'non-compliant' ? 'Nicht konform' :
|
||||
f === 'partial' ? 'Teilweise' : 'Konform'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Checklist Items */}
|
||||
<div className="space-y-4">
|
||||
{filteredItems.map(item => (
|
||||
<ChecklistItemCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
onStatusChange={(status) => handleStatusChange(item.id, status)}
|
||||
onNotesChange={(notes) => handleNotesChange(item.id, notes)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredItems.length === 0 && state.requirements.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 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>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Keine Eintraege gefunden</h3>
|
||||
<p className="mt-2 text-gray-500">Passen Sie den Filter an.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,521 +0,0 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Compliance Hub Page (SDK Version - Zusatzmodul)
|
||||
*
|
||||
* Central compliance management dashboard with:
|
||||
* - Compliance Score Overview
|
||||
* - Quick Access to all compliance modules (SDK paths)
|
||||
* - Control-Mappings with statistics
|
||||
* - Audit Findings
|
||||
* - Regulations overview
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
|
||||
// Types
|
||||
interface DashboardData {
|
||||
compliance_score: number
|
||||
total_regulations: number
|
||||
total_requirements: number
|
||||
total_controls: number
|
||||
controls_by_status: Record<string, number>
|
||||
controls_by_domain: Record<string, Record<string, number>>
|
||||
total_evidence: number
|
||||
evidence_by_status: Record<string, number>
|
||||
total_risks: number
|
||||
risks_by_level: Record<string, number>
|
||||
}
|
||||
|
||||
interface Regulation {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
full_name: string
|
||||
regulation_type: string
|
||||
effective_date: string | null
|
||||
description: string
|
||||
requirement_count: number
|
||||
}
|
||||
|
||||
interface MappingsData {
|
||||
total: number
|
||||
by_regulation: Record<string, number>
|
||||
}
|
||||
|
||||
interface FindingsData {
|
||||
major_count: number
|
||||
minor_count: number
|
||||
ofi_count: number
|
||||
total: number
|
||||
open_majors: number
|
||||
open_minors: number
|
||||
}
|
||||
|
||||
const DOMAIN_LABELS: Record<string, string> = {
|
||||
gov: 'Governance',
|
||||
priv: 'Datenschutz',
|
||||
iam: 'Identity & Access',
|
||||
crypto: 'Kryptografie',
|
||||
sdlc: 'Secure Dev',
|
||||
ops: 'Operations',
|
||||
ai: 'KI-spezifisch',
|
||||
cra: 'Supply Chain',
|
||||
aud: 'Audit',
|
||||
}
|
||||
|
||||
export default function ComplianceHubPage() {
|
||||
const [dashboard, setDashboard] = useState<DashboardData | null>(null)
|
||||
const [regulations, setRegulations] = useState<Regulation[]>([])
|
||||
const [mappings, setMappings] = useState<MappingsData | null>(null)
|
||||
const [findings, setFindings] = useState<FindingsData | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [seeding, setSeeding] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [])
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const [dashboardRes, regulationsRes, mappingsRes, findingsRes] = await Promise.all([
|
||||
fetch('/api/admin/compliance/dashboard'),
|
||||
fetch('/api/admin/compliance/regulations'),
|
||||
fetch('/api/admin/compliance/mappings'),
|
||||
fetch('/api/admin/compliance/isms/findings/summary'),
|
||||
])
|
||||
|
||||
if (dashboardRes.ok) {
|
||||
setDashboard(await dashboardRes.json())
|
||||
}
|
||||
if (regulationsRes.ok) {
|
||||
const data = await regulationsRes.json()
|
||||
setRegulations(data.regulations || [])
|
||||
}
|
||||
if (mappingsRes.ok) {
|
||||
const data = await mappingsRes.json()
|
||||
setMappings(data)
|
||||
}
|
||||
if (findingsRes.ok) {
|
||||
const data = await findingsRes.json()
|
||||
setFindings(data)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load compliance data:', err)
|
||||
setError('Verbindung zum Backend fehlgeschlagen')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const seedDatabase = async () => {
|
||||
setSeeding(true)
|
||||
try {
|
||||
const res = await fetch('/api/admin/compliance/seed', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ force: false }),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
const result = await res.json()
|
||||
alert(`Datenbank erfolgreich initialisiert!\n\nRegulations: ${result.counts?.regulations || 0}\nControls: ${result.counts?.controls || 0}\nRequirements: ${result.counts?.requirements || 0}`)
|
||||
loadData()
|
||||
} else {
|
||||
const error = await res.text()
|
||||
alert(`Fehler beim Seeding: ${error}`)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Seeding failed:', err)
|
||||
alert('Fehler beim Initialisieren der Datenbank')
|
||||
} finally {
|
||||
setSeeding(false)
|
||||
}
|
||||
}
|
||||
|
||||
const score = dashboard?.compliance_score || 0
|
||||
const scoreColor = score >= 80 ? 'text-green-600' : score >= 60 ? 'text-yellow-600' : 'text-red-600'
|
||||
const scoreBgColor = score >= 80 ? 'bg-green-500' : score >= 60 ? 'bg-yellow-500' : 'bg-red-500'
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Title Card (Zusatzmodul - no StepHeader) */}
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||
<h1 className="text-2xl font-bold text-slate-900">Compliance Hub</h1>
|
||||
<p className="text-slate-500 mt-1">
|
||||
Zentrale Verwaltung aller Compliance-Anforderungen nach DSGVO, AI Act, BSI TR-03161 und weiteren Regulierungen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error Banner */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-center gap-3">
|
||||
<svg className="w-5 h-5 text-red-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span className="text-red-700">{error}</span>
|
||||
<button onClick={loadData} className="ml-auto text-red-600 hover:text-red-800 text-sm font-medium">
|
||||
Erneut versuchen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Seed Button if no data */}
|
||||
{!loading && (dashboard?.total_controls || 0) === 0 && (
|
||||
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-yellow-800">Keine Compliance-Daten vorhanden</p>
|
||||
<p className="text-sm text-yellow-700">Initialisieren Sie die Datenbank mit den Seed-Daten.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={seedDatabase}
|
||||
disabled={seeding}
|
||||
className="px-4 py-2 bg-yellow-600 text-white rounded-lg hover:bg-yellow-700 disabled:opacity-50"
|
||||
>
|
||||
{seeding ? 'Initialisiere...' : 'Datenbank initialisieren'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-4">Schnellzugriff</h3>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-6 gap-4">
|
||||
<Link
|
||||
href="/sdk/audit-checklist"
|
||||
className="p-4 rounded-lg border border-slate-200 hover:border-purple-500 hover:bg-purple-50 transition-colors text-center"
|
||||
>
|
||||
<div className="text-purple-600 mb-2 flex justify-center">
|
||||
<svg className="w-8 h-8" 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-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="font-medium text-slate-900 text-sm">Audit Checkliste</p>
|
||||
<p className="text-xs text-slate-500 mt-1">{dashboard?.total_requirements || '...'} Anforderungen</p>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/sdk/controls"
|
||||
className="p-4 rounded-lg border border-slate-200 hover:border-green-500 hover:bg-green-50 transition-colors text-center"
|
||||
>
|
||||
<div className="text-green-600 mb-2 flex justify-center">
|
||||
<svg className="w-8 h-8" 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>
|
||||
</div>
|
||||
<p className="font-medium text-slate-900 text-sm">Controls</p>
|
||||
<p className="text-xs text-slate-500 mt-1">{dashboard?.total_controls || '...'} Massnahmen</p>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/sdk/evidence"
|
||||
className="p-4 rounded-lg border border-slate-200 hover:border-blue-500 hover:bg-blue-50 transition-colors text-center"
|
||||
>
|
||||
<div className="text-blue-600 mb-2 flex justify-center">
|
||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="font-medium text-slate-900 text-sm">Evidence</p>
|
||||
<p className="text-xs text-slate-500 mt-1">Nachweise</p>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/sdk/risks"
|
||||
className="p-4 rounded-lg border border-slate-200 hover:border-red-500 hover:bg-red-50 transition-colors text-center"
|
||||
>
|
||||
<div className="text-red-600 mb-2 flex justify-center">
|
||||
<svg className="w-8 h-8" 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>
|
||||
<p className="font-medium text-slate-900 text-sm">Risk Matrix</p>
|
||||
<p className="text-xs text-slate-500 mt-1">5x5 Risiken</p>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/sdk/modules"
|
||||
className="p-4 rounded-lg border border-slate-200 hover:border-pink-500 hover:bg-pink-50 transition-colors text-center"
|
||||
>
|
||||
<div className="text-pink-600 mb-2 flex justify-center">
|
||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="font-medium text-slate-900 text-sm">Service Registry</p>
|
||||
<p className="text-xs text-slate-500 mt-1">Module</p>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/sdk/audit-report"
|
||||
className="p-4 rounded-lg border border-slate-200 hover:border-orange-500 hover:bg-orange-50 transition-colors text-center"
|
||||
>
|
||||
<div className="text-orange-600 mb-2 flex justify-center">
|
||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="font-medium text-slate-900 text-sm">Audit Report</p>
|
||||
<p className="text-xs text-slate-500 mt-1">PDF Export</p>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-purple-600" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Score and Stats Row */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-5 gap-4">
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||
<h3 className="text-sm font-medium text-slate-500 mb-4">Compliance Score</h3>
|
||||
<div className={`text-5xl font-bold ${scoreColor}`}>
|
||||
{score.toFixed(0)}%
|
||||
</div>
|
||||
<div className="mt-4 h-2 bg-slate-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full transition-all duration-500 ${scoreBgColor}`}
|
||||
style={{ width: `${score}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-slate-500">
|
||||
{dashboard?.controls_by_status?.pass || 0} von {dashboard?.total_controls || 0} Controls bestanden
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">Verordnungen</p>
|
||||
<p className="text-2xl font-bold text-slate-900">{dashboard?.total_regulations || 0}</p>
|
||||
</div>
|
||||
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-slate-500">{dashboard?.total_requirements || 0} Anforderungen</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">Controls</p>
|
||||
<p className="text-2xl font-bold text-slate-900">{dashboard?.total_controls || 0}</p>
|
||||
</div>
|
||||
<div className="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-green-600" 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>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-slate-500">{dashboard?.controls_by_status?.pass || 0} bestanden</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">Nachweise</p>
|
||||
<p className="text-2xl font-bold text-slate-900">{dashboard?.total_evidence || 0}</p>
|
||||
</div>
|
||||
<div className="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-slate-500">{dashboard?.evidence_by_status?.valid || 0} aktiv</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">Risiken</p>
|
||||
<p className="text-2xl font-bold text-slate-900">{dashboard?.total_risks || 0}</p>
|
||||
</div>
|
||||
<div className="w-10 h-10 bg-red-100 rounded-lg flex items-center justify-center">
|
||||
<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>
|
||||
<p className="mt-2 text-sm text-slate-500">
|
||||
{(dashboard?.risks_by_level?.high || 0) + (dashboard?.risks_by_level?.critical || 0)} kritisch
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Control-Mappings & Findings Row */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-slate-900">Control-Mappings</h3>
|
||||
<Link href="/sdk/controls" className="text-sm text-purple-600 hover:text-purple-700">
|
||||
Alle anzeigen →
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-center gap-6 mb-4">
|
||||
<div>
|
||||
<p className="text-4xl font-bold text-purple-600">{mappings?.total || 474}</p>
|
||||
<p className="text-sm text-slate-500">Mappings gesamt</p>
|
||||
</div>
|
||||
<div className="flex-1 h-16 bg-slate-50 rounded-lg p-3">
|
||||
<p className="text-xs text-slate-500 mb-1">Nach Verordnung</p>
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{mappings?.by_regulation && Object.entries(mappings.by_regulation).slice(0, 5).map(([reg, count]) => (
|
||||
<span key={reg} className="px-2 py-0.5 bg-purple-100 text-purple-700 rounded text-xs">
|
||||
{reg}: {count}
|
||||
</span>
|
||||
))}
|
||||
{!mappings?.by_regulation && (
|
||||
<>
|
||||
<span className="px-2 py-0.5 bg-purple-100 text-purple-700 rounded text-xs">GDPR: 180</span>
|
||||
<span className="px-2 py-0.5 bg-blue-100 text-blue-700 rounded text-xs">AI Act: 95</span>
|
||||
<span className="px-2 py-0.5 bg-green-100 text-green-700 rounded text-xs">BSI: 120</span>
|
||||
<span className="px-2 py-0.5 bg-orange-100 text-orange-700 rounded text-xs">CRA: 79</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-slate-600">
|
||||
Automatisch generierte Verknuepfungen zwischen {dashboard?.total_controls || 44} Controls
|
||||
und {dashboard?.total_requirements || 558} Anforderungen aus {dashboard?.total_regulations || 19} Verordnungen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-slate-900">Audit Findings</h3>
|
||||
<Link href="/sdk/audit-checklist" className="text-sm text-purple-600 hover:text-purple-700">
|
||||
Audit Checkliste →
|
||||
</Link>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
<div className="p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="w-3 h-3 bg-red-500 rounded-full" />
|
||||
<span className="text-sm font-medium text-red-800">Hauptabweichungen</span>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-red-600">{findings?.open_majors || 0}</p>
|
||||
<p className="text-xs text-red-600">offen (blockiert Zertifizierung)</p>
|
||||
</div>
|
||||
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="w-3 h-3 bg-yellow-500 rounded-full" />
|
||||
<span className="text-sm font-medium text-yellow-800">Nebenabweichungen</span>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-yellow-600">{findings?.open_minors || 0}</p>
|
||||
<p className="text-xs text-yellow-600">offen (erfordert CAPA)</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-slate-500">
|
||||
Gesamt: {findings?.total || 0} Findings ({findings?.major_count || 0} Major, {findings?.minor_count || 0} Minor, {findings?.ofi_count || 0} OFI)
|
||||
</span>
|
||||
{(findings?.open_majors || 0) === 0 ? (
|
||||
<span className="px-2 py-1 bg-green-100 text-green-700 rounded-full text-xs font-medium">
|
||||
Zertifizierung moeglich
|
||||
</span>
|
||||
) : (
|
||||
<span className="px-2 py-1 bg-red-100 text-red-700 rounded-full text-xs font-medium">
|
||||
Zertifizierung blockiert
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Domain Chart */}
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-4">Controls nach Domain</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{Object.entries(dashboard?.controls_by_domain || {}).map(([domain, stats]) => {
|
||||
const total = stats.total || 0
|
||||
const pass = stats.pass || 0
|
||||
const partial = stats.partial || 0
|
||||
const passPercent = total > 0 ? ((pass + partial * 0.5) / total) * 100 : 0
|
||||
|
||||
return (
|
||||
<div key={domain} className="p-3 rounded-lg bg-slate-50">
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span className="font-medium text-slate-700">
|
||||
{DOMAIN_LABELS[domain] || domain.toUpperCase()}
|
||||
</span>
|
||||
<span className="text-slate-500">
|
||||
{pass}/{total} ({passPercent.toFixed(0)}%)
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 bg-slate-200 rounded-full overflow-hidden flex">
|
||||
<div className="bg-green-500 h-full" style={{ width: `${(pass / total) * 100}%` }} />
|
||||
<div className="bg-yellow-500 h-full" style={{ width: `${(partial / total) * 100}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Regulations Table */}
|
||||
<div className="bg-white rounded-xl shadow-sm border overflow-hidden">
|
||||
<div className="p-4 border-b flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-slate-900">Verordnungen & Standards ({regulations.length})</h3>
|
||||
<button onClick={loadData} className="text-sm text-purple-600 hover:text-purple-700">
|
||||
Aktualisieren
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-slate-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Code</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Name</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Typ</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-slate-500 uppercase">Anforderungen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-200">
|
||||
{regulations.slice(0, 15).map((reg) => (
|
||||
<tr key={reg.id} className="hover:bg-slate-50">
|
||||
<td className="px-4 py-3">
|
||||
<span className="font-mono font-medium text-purple-600">{reg.code}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<p className="font-medium text-slate-900">{reg.name}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${
|
||||
reg.regulation_type === 'eu_regulation' ? 'bg-blue-100 text-blue-700' :
|
||||
reg.regulation_type === 'eu_directive' ? 'bg-purple-100 text-purple-700' :
|
||||
reg.regulation_type === 'bsi_standard' ? 'bg-green-100 text-green-700' :
|
||||
'bg-slate-100 text-slate-700'
|
||||
}`}>
|
||||
{reg.regulation_type === 'eu_regulation' ? 'EU-VO' :
|
||||
reg.regulation_type === 'eu_directive' ? 'EU-RL' :
|
||||
reg.regulation_type === 'bsi_standard' ? 'BSI' :
|
||||
reg.regulation_type === 'de_law' ? 'DE' : reg.regulation_type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span className="font-medium">{reg.requirement_count}</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,878 +0,0 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Consent Management Page (SDK Version)
|
||||
*
|
||||
* Admin interface for managing:
|
||||
* - Documents (AGB, Privacy, etc.)
|
||||
* - Document Versions
|
||||
* - Email Templates
|
||||
* - GDPR Processes (Art. 15-21)
|
||||
* - Statistics
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useSDK } from '@/lib/sdk'
|
||||
import StepHeader from '@/components/sdk/StepHeader/StepHeader'
|
||||
|
||||
// API Proxy URL (avoids CORS issues)
|
||||
const API_BASE = '/api/admin/consent'
|
||||
|
||||
type Tab = 'documents' | 'versions' | 'emails' | 'gdpr' | 'stats'
|
||||
|
||||
interface Document {
|
||||
id: string
|
||||
type: string
|
||||
name: string
|
||||
description: string
|
||||
mandatory: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
interface Version {
|
||||
id: string
|
||||
document_id: string
|
||||
version: string
|
||||
language: string
|
||||
title: string
|
||||
content: string
|
||||
status: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
// Email template editor types
|
||||
interface EmailTemplateData {
|
||||
key: string
|
||||
subject: string
|
||||
body: string
|
||||
}
|
||||
|
||||
export default function ConsentManagementPage() {
|
||||
const { state } = useSDK()
|
||||
const [activeTab, setActiveTab] = useState<Tab>('documents')
|
||||
const [documents, setDocuments] = useState<Document[]>([])
|
||||
const [versions, setVersions] = useState<Version[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [selectedDocument, setSelectedDocument] = useState<string>('')
|
||||
|
||||
// Stats state
|
||||
const [consentStats, setConsentStats] = useState<{ activeConsents: number; documentCount: number; openDSRs: number }>({ activeConsents: 0, documentCount: 0, openDSRs: 0 })
|
||||
|
||||
// GDPR tab state
|
||||
const [dsrCounts, setDsrCounts] = useState<Record<string, number>>({})
|
||||
const [dsrOverview, setDsrOverview] = useState<{ open: number; completed: number; in_progress: number; overdue: number }>({ open: 0, completed: 0, in_progress: 0, overdue: 0 })
|
||||
|
||||
// Email template editor state
|
||||
const [editingTemplate, setEditingTemplate] = useState<EmailTemplateData | null>(null)
|
||||
const [previewTemplate, setPreviewTemplate] = useState<EmailTemplateData | null>(null)
|
||||
const [savedTemplates, setSavedTemplates] = useState<Record<string, EmailTemplateData>>({})
|
||||
|
||||
// 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()
|
||||
}
|
||||
}, [activeTab, selectedDocument, authToken])
|
||||
|
||||
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/dsgvo/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 || []
|
||||
const now = new Date()
|
||||
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/dsgvo/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))
|
||||
setEditingTemplate(null)
|
||||
}
|
||||
|
||||
const tabs: { id: Tab; label: string }[] = [
|
||||
{ id: 'documents', label: 'Dokumente' },
|
||||
{ id: 'versions', label: 'Versionen' },
|
||||
{ id: 'emails', label: 'E-Mail Vorlagen' },
|
||||
{ id: 'gdpr', label: 'DSGVO Prozesse' },
|
||||
{ id: 'stats', label: 'Statistiken' },
|
||||
]
|
||||
|
||||
// 16 Lifecycle Email Templates
|
||||
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
|
||||
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',
|
||||
},
|
||||
]
|
||||
|
||||
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' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<StepHeader stepId="consent-management" showProgress={true} />
|
||||
|
||||
{/* Token Input */}
|
||||
{!authToken && (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
Admin Token
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="JWT Token eingeben..."
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||||
onChange={(e) => {
|
||||
setAuthToken(e.target.value)
|
||||
localStorage.setItem('bp_admin_token', e.target.value)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div>
|
||||
<div className="flex gap-1 bg-slate-100 p-1 rounded-lg w-fit">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-md transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'bg-white text-slate-900 shadow-sm'
|
||||
: 'text-slate-600 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div>
|
||||
{error && (
|
||||
<div className="mb-4 p-4 bg-red-50 border border-red-200 text-red-700 rounded-lg">
|
||||
{error}
|
||||
<button
|
||||
onClick={() => setError(null)}
|
||||
className="ml-4 text-red-500 hover:text-red-700"
|
||||
>
|
||||
X
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white rounded-xl border border-slate-200 shadow-sm">
|
||||
{/* Documents Tab */}
|
||||
{activeTab === 'documents' && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Versions Tab */}
|
||||
{activeTab === 'versions' && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Emails Tab - 16 Lifecycle Templates */}
|
||||
{activeTab === 'emails' && (
|
||||
<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">16 Lifecycle-Vorlagen fuer automatisierte Kommunikation</p>
|
||||
</div>
|
||||
<button 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>
|
||||
|
||||
{/* Category Filter */}
|
||||
<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 */}
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* GDPR Processes Tab - Articles 15-21 */}
|
||||
{activeTab === 'gdpr' && (
|
||||
<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 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>
|
||||
|
||||
{/* GDPR Process Cards */}
|
||||
<div className="space-y-4">
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* Stats Tab */}
|
||||
{activeTab === 'stats' && (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Email Template Edit Modal */}
|
||||
{editingTemplate && (
|
||||
<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={() => setEditingTemplate(null)} 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) => setEditingTemplate({ ...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) => setEditingTemplate({ ...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={() => setEditingTemplate(null)} className="px-4 py-2 text-sm text-slate-600 hover:bg-slate-100 rounded-lg">
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => saveEmailTemplate(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>
|
||||
)}
|
||||
|
||||
{/* Email Template Preview Modal */}
|
||||
{previewTemplate && (
|
||||
<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={() => setPreviewTemplate(null)} 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">
|
||||
{previewTemplate.subject
|
||||
.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')
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border border-slate-200 rounded-lg p-4 whitespace-pre-wrap text-sm text-slate-700">
|
||||
{previewTemplate.body
|
||||
.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')
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t border-slate-200 flex justify-end">
|
||||
<button onClick={() => setPreviewTemplate(null)} className="px-4 py-2 text-sm text-slate-600 hover:bg-slate-100 rounded-lg">
|
||||
Schliessen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,328 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useSDK } from '@/lib/sdk'
|
||||
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
interface LegalDocument {
|
||||
id: string
|
||||
type: 'privacy-policy' | 'terms' | 'cookie-policy' | 'imprint' | 'dpa'
|
||||
name: string
|
||||
version: string
|
||||
language: string
|
||||
status: 'draft' | 'active' | 'archived'
|
||||
lastUpdated: Date
|
||||
publishedAt: Date | null
|
||||
author: string
|
||||
changes: string[]
|
||||
}
|
||||
|
||||
interface ApiDocument {
|
||||
id: string
|
||||
type: string
|
||||
name: string
|
||||
description: string
|
||||
mandatory: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
// Map API document type to UI type
|
||||
function mapDocumentType(apiType: string): LegalDocument['type'] {
|
||||
const mapping: Record<string, LegalDocument['type']> = {
|
||||
'privacy_policy': 'privacy-policy',
|
||||
'privacy-policy': 'privacy-policy',
|
||||
'terms': 'terms',
|
||||
'terms_of_service': 'terms',
|
||||
'cookie_policy': 'cookie-policy',
|
||||
'cookie-policy': 'cookie-policy',
|
||||
'imprint': 'imprint',
|
||||
'dpa': 'dpa',
|
||||
'avv': 'dpa',
|
||||
}
|
||||
return mapping[apiType] || 'terms'
|
||||
}
|
||||
|
||||
// Transform API response to UI format
|
||||
function transformApiDocument(doc: ApiDocument): LegalDocument {
|
||||
return {
|
||||
id: doc.id,
|
||||
type: mapDocumentType(doc.type),
|
||||
name: doc.name,
|
||||
version: '1.0',
|
||||
language: 'de',
|
||||
status: 'active',
|
||||
lastUpdated: new Date(doc.updated_at),
|
||||
publishedAt: new Date(doc.created_at),
|
||||
author: 'System',
|
||||
changes: doc.description ? [doc.description] : [],
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// COMPONENTS
|
||||
// =============================================================================
|
||||
|
||||
function DocumentCard({ document }: { document: LegalDocument }) {
|
||||
const typeColors = {
|
||||
'privacy-policy': 'bg-blue-100 text-blue-700',
|
||||
terms: 'bg-green-100 text-green-700',
|
||||
'cookie-policy': 'bg-yellow-100 text-yellow-700',
|
||||
imprint: 'bg-gray-100 text-gray-700',
|
||||
dpa: 'bg-purple-100 text-purple-700',
|
||||
}
|
||||
|
||||
const typeLabels = {
|
||||
'privacy-policy': 'Datenschutz',
|
||||
terms: 'AGB',
|
||||
'cookie-policy': 'Cookie-Richtlinie',
|
||||
imprint: 'Impressum',
|
||||
dpa: 'AVV',
|
||||
}
|
||||
|
||||
const statusColors = {
|
||||
draft: 'bg-yellow-100 text-yellow-700',
|
||||
active: 'bg-green-100 text-green-700',
|
||||
archived: 'bg-gray-100 text-gray-500',
|
||||
}
|
||||
|
||||
const statusLabels = {
|
||||
draft: 'Entwurf',
|
||||
active: 'Aktiv',
|
||||
archived: 'Archiviert',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-xl border-2 p-6 ${
|
||||
document.status === 'draft' ? 'border-yellow-200' : 'border-gray-200'
|
||||
}`}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${typeColors[document.type]}`}>
|
||||
{typeLabels[document.type]}
|
||||
</span>
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${statusColors[document.status]}`}>
|
||||
{statusLabels[document.status]}
|
||||
</span>
|
||||
<span className="px-2 py-1 text-xs bg-gray-100 text-gray-600 rounded uppercase">
|
||||
{document.language}
|
||||
</span>
|
||||
<span className="px-2 py-1 text-xs bg-gray-100 text-gray-600 rounded">
|
||||
v{document.version}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">{document.name}</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{document.changes.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<span className="text-sm text-gray-500">Letzte Aenderungen:</span>
|
||||
<ul className="mt-1 text-sm text-gray-600 list-disc list-inside">
|
||||
{document.changes.slice(0, 2).map((change, i) => (
|
||||
<li key={i}>{change}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between text-sm">
|
||||
<div className="text-gray-500">
|
||||
<span>Autor: {document.author}</span>
|
||||
<span className="mx-2">|</span>
|
||||
<span>Aktualisiert: {document.lastUpdated.toLocaleDateString('de-DE')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="px-3 py-1 text-purple-600 hover:bg-purple-50 rounded-lg transition-colors">
|
||||
Bearbeiten
|
||||
</button>
|
||||
<button className="px-3 py-1 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
Vorschau
|
||||
</button>
|
||||
{document.status === 'draft' && (
|
||||
<button className="px-3 py-1 bg-green-50 text-green-700 hover:bg-green-100 rounded-lg transition-colors">
|
||||
Veroeffentlichen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN PAGE
|
||||
// =============================================================================
|
||||
|
||||
export default function ConsentPage() {
|
||||
const { state } = useSDK()
|
||||
const [documents, setDocuments] = useState<LegalDocument[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [filter, setFilter] = useState<string>('all')
|
||||
|
||||
useEffect(() => {
|
||||
loadDocuments()
|
||||
}, [])
|
||||
|
||||
async function loadDocuments() {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const token = localStorage.getItem('bp_admin_token')
|
||||
const res = await fetch('/api/admin/consent/documents', {
|
||||
headers: token ? { 'Authorization': `Bearer ${token}` } : {}
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
const apiDocs: ApiDocument[] = data.documents || []
|
||||
setDocuments(apiDocs.map(transformApiDocument))
|
||||
} else {
|
||||
setError('Fehler beim Laden der Dokumente')
|
||||
}
|
||||
} catch {
|
||||
setError('Verbindungsfehler zum Server')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const filteredDocuments = filter === 'all'
|
||||
? documents
|
||||
: documents.filter(d => d.type === filter || d.status === filter)
|
||||
|
||||
const activeCount = documents.filter(d => d.status === 'active').length
|
||||
const draftCount = documents.filter(d => d.status === 'draft').length
|
||||
|
||||
const stepInfo = STEP_EXPLANATIONS['consent']
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Step Header */}
|
||||
<StepHeader
|
||||
stepId="consent"
|
||||
title={stepInfo.title}
|
||||
description={stepInfo.description}
|
||||
explanation={stepInfo.explanation}
|
||||
tips={stepInfo.tips}
|
||||
>
|
||||
<button 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>
|
||||
Neues Dokument
|
||||
</button>
|
||||
</StepHeader>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="text-sm text-gray-500">Gesamt</div>
|
||||
<div className="text-3xl font-bold text-gray-900">{documents.length}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-green-200 p-6">
|
||||
<div className="text-sm text-green-600">Aktiv</div>
|
||||
<div className="text-3xl font-bold text-green-600">{activeCount}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-yellow-200 p-6">
|
||||
<div className="text-sm text-yellow-600">Entwuerfe</div>
|
||||
<div className="text-3xl font-bold text-yellow-600">{draftCount}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-blue-200 p-6">
|
||||
<div className="text-sm text-blue-600">Sprachen</div>
|
||||
<div className="text-3xl font-bold text-blue-600">
|
||||
{[...new Set(documents.map(d => d.language))].length}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loading / Error */}
|
||||
{loading && (
|
||||
<div className="text-center py-8 text-gray-500">Lade Dokumente...</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="bg-gradient-to-r from-purple-50 to-blue-50 rounded-xl border border-purple-200 p-6">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Schnellaktionen</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<button className="p-4 bg-white rounded-lg border border-gray-200 hover:border-purple-300 hover:shadow transition-all text-center">
|
||||
<svg className="w-8 h-8 mx-auto text-blue-600 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium">Datenschutz generieren</span>
|
||||
</button>
|
||||
<button className="p-4 bg-white rounded-lg border border-gray-200 hover:border-purple-300 hover:shadow transition-all text-center">
|
||||
<svg className="w-8 h-8 mx-auto text-green-600 mb-2" 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 2" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium">AGB generieren</span>
|
||||
</button>
|
||||
<button className="p-4 bg-white rounded-lg border border-gray-200 hover:border-purple-300 hover:shadow transition-all text-center">
|
||||
<svg className="w-8 h-8 mx-auto text-yellow-600 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium">Cookie-Richtlinie</span>
|
||||
</button>
|
||||
<button className="p-4 bg-white rounded-lg border border-gray-200 hover:border-purple-300 hover:shadow transition-all text-center">
|
||||
<svg className="w-8 h-8 mx-auto text-purple-600 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium">AVV-Vorlage</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm text-gray-500">Filter:</span>
|
||||
{['all', 'privacy-policy', 'terms', 'cookie-policy', 'dpa', 'active', 'draft'].map(f => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
className={`px-3 py-1 text-sm rounded-full transition-colors ${
|
||||
filter === f
|
||||
? 'bg-purple-600 text-white'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{f === 'all' ? 'Alle' :
|
||||
f === 'privacy-policy' ? 'Datenschutz' :
|
||||
f === 'terms' ? 'AGB' :
|
||||
f === 'cookie-policy' ? 'Cookie' :
|
||||
f === 'dpa' ? 'AVV' :
|
||||
f === 'active' ? 'Aktiv' : 'Entwuerfe'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Documents List */}
|
||||
<div className="space-y-4">
|
||||
{filteredDocuments.map(document => (
|
||||
<DocumentCard key={document.id} document={document} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredDocuments.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 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Keine Dokumente gefunden</h3>
|
||||
<p className="mt-2 text-gray-500">Passen Sie den Filter an oder erstellen Sie ein neues Dokument.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,484 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useSDK, Control as SDKControl, ControlType, ImplementationStatus, RiskSeverity } from '@/lib/sdk'
|
||||
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
type DisplayControlType = 'preventive' | 'detective' | 'corrective'
|
||||
type DisplayCategory = 'technical' | 'organizational' | 'physical'
|
||||
type DisplayStatus = 'implemented' | 'partial' | 'planned' | 'not-implemented'
|
||||
|
||||
// DisplayControl uses SDK Control properties but adds UI-specific fields
|
||||
interface DisplayControl {
|
||||
// From SDKControl
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
type: ControlType
|
||||
category: string
|
||||
implementationStatus: ImplementationStatus
|
||||
evidence: string[]
|
||||
owner: string | null
|
||||
dueDate: Date | null
|
||||
// UI-specific fields
|
||||
code: string
|
||||
displayType: DisplayControlType
|
||||
displayCategory: DisplayCategory
|
||||
displayStatus: DisplayStatus
|
||||
effectivenessPercent: number
|
||||
linkedRequirements: string[]
|
||||
lastReview: Date
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// =============================================================================
|
||||
|
||||
function mapControlTypeToDisplay(type: ControlType): DisplayCategory {
|
||||
switch (type) {
|
||||
case 'TECHNICAL': return 'technical'
|
||||
case 'ORGANIZATIONAL': return 'organizational'
|
||||
case 'PHYSICAL': return 'physical'
|
||||
default: return 'technical'
|
||||
}
|
||||
}
|
||||
|
||||
function mapStatusToDisplay(status: ImplementationStatus): DisplayStatus {
|
||||
switch (status) {
|
||||
case 'IMPLEMENTED': return 'implemented'
|
||||
case 'PARTIAL': return 'partial'
|
||||
case 'NOT_IMPLEMENTED': return 'not-implemented'
|
||||
default: return 'not-implemented'
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CONTROL TEMPLATES
|
||||
// =============================================================================
|
||||
|
||||
interface ControlTemplate {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
description: string
|
||||
type: ControlType
|
||||
displayType: DisplayControlType
|
||||
displayCategory: DisplayCategory
|
||||
category: string
|
||||
owner: string
|
||||
linkedRequirements: string[]
|
||||
}
|
||||
|
||||
const controlTemplates: ControlTemplate[] = [
|
||||
{
|
||||
id: 'ctrl-tom-001',
|
||||
code: 'TOM-001',
|
||||
name: 'Zugriffskontrolle',
|
||||
description: 'Rollenbasierte Zugriffskontrolle (RBAC) fuer alle Systeme',
|
||||
type: 'TECHNICAL',
|
||||
displayType: 'preventive',
|
||||
displayCategory: 'technical',
|
||||
category: 'Zutrittskontrolle',
|
||||
owner: 'IT Security',
|
||||
linkedRequirements: ['req-gdpr-32'],
|
||||
},
|
||||
{
|
||||
id: 'ctrl-tom-002',
|
||||
code: 'TOM-002',
|
||||
name: 'Verschluesselung',
|
||||
description: 'Verschluesselung von Daten at rest und in transit',
|
||||
type: 'TECHNICAL',
|
||||
displayType: 'preventive',
|
||||
displayCategory: 'technical',
|
||||
category: 'Weitergabekontrolle',
|
||||
owner: 'IT Security',
|
||||
linkedRequirements: ['req-gdpr-32'],
|
||||
},
|
||||
{
|
||||
id: 'ctrl-org-001',
|
||||
code: 'ORG-001',
|
||||
name: 'Datenschutzschulung',
|
||||
description: 'Jaehrliche Datenschutzschulung fuer alle Mitarbeiter',
|
||||
type: 'ORGANIZATIONAL',
|
||||
displayType: 'preventive',
|
||||
displayCategory: 'organizational',
|
||||
category: 'Schulung',
|
||||
owner: 'HR',
|
||||
linkedRequirements: ['req-gdpr-6', 'req-gdpr-32'],
|
||||
},
|
||||
{
|
||||
id: 'ctrl-det-001',
|
||||
code: 'DET-001',
|
||||
name: 'Logging und Monitoring',
|
||||
description: 'Umfassendes Logging aller Datenzugriffe',
|
||||
type: 'TECHNICAL',
|
||||
displayType: 'detective',
|
||||
displayCategory: 'technical',
|
||||
category: 'Eingabekontrolle',
|
||||
owner: 'IT Operations',
|
||||
linkedRequirements: ['req-gdpr-32', 'req-nis2-21'],
|
||||
},
|
||||
{
|
||||
id: 'ctrl-cor-001',
|
||||
code: 'COR-001',
|
||||
name: 'Incident Response',
|
||||
description: 'Prozess zur Behandlung von Datenschutzvorfaellen',
|
||||
type: 'ORGANIZATIONAL',
|
||||
displayType: 'corrective',
|
||||
displayCategory: 'organizational',
|
||||
category: 'Incident Management',
|
||||
owner: 'CISO',
|
||||
linkedRequirements: ['req-gdpr-32', 'req-nis2-21'],
|
||||
},
|
||||
{
|
||||
id: 'ctrl-ai-001',
|
||||
code: 'AI-001',
|
||||
name: 'KI-Risikomonitoring',
|
||||
description: 'Kontinuierliche Ueberwachung von KI-Systemrisiken',
|
||||
type: 'TECHNICAL',
|
||||
displayType: 'detective',
|
||||
displayCategory: 'technical',
|
||||
category: 'KI-Governance',
|
||||
owner: 'AI Team',
|
||||
linkedRequirements: ['req-ai-act-9', 'req-ai-act-13'],
|
||||
},
|
||||
]
|
||||
|
||||
// =============================================================================
|
||||
// COMPONENTS
|
||||
// =============================================================================
|
||||
|
||||
function ControlCard({
|
||||
control,
|
||||
onStatusChange,
|
||||
onEffectivenessChange,
|
||||
}: {
|
||||
control: DisplayControl
|
||||
onStatusChange: (status: ImplementationStatus) => void
|
||||
onEffectivenessChange: (effectivenessPercent: number) => void
|
||||
}) {
|
||||
const [showEffectivenessSlider, setShowEffectivenessSlider] = useState(false)
|
||||
|
||||
const typeColors = {
|
||||
preventive: 'bg-blue-100 text-blue-700',
|
||||
detective: 'bg-purple-100 text-purple-700',
|
||||
corrective: 'bg-orange-100 text-orange-700',
|
||||
}
|
||||
|
||||
const categoryColors = {
|
||||
technical: 'bg-green-100 text-green-700',
|
||||
organizational: 'bg-yellow-100 text-yellow-700',
|
||||
physical: 'bg-gray-100 text-gray-700',
|
||||
}
|
||||
|
||||
const statusColors = {
|
||||
implemented: 'border-green-200 bg-green-50',
|
||||
partial: 'border-yellow-200 bg-yellow-50',
|
||||
planned: 'border-blue-200 bg-blue-50',
|
||||
'not-implemented': 'border-red-200 bg-red-50',
|
||||
}
|
||||
|
||||
const statusLabels = {
|
||||
implemented: 'Implementiert',
|
||||
partial: 'Teilweise',
|
||||
planned: 'Geplant',
|
||||
'not-implemented': 'Nicht implementiert',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-xl border-2 p-6 ${statusColors[control.displayStatus]}`}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="px-2 py-1 text-xs bg-gray-100 text-gray-700 rounded font-mono">
|
||||
{control.code}
|
||||
</span>
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${typeColors[control.displayType]}`}>
|
||||
{control.displayType === 'preventive' ? 'Praeventiv' :
|
||||
control.displayType === 'detective' ? 'Detektiv' : 'Korrektiv'}
|
||||
</span>
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${categoryColors[control.displayCategory]}`}>
|
||||
{control.displayCategory === 'technical' ? 'Technisch' :
|
||||
control.displayCategory === 'organizational' ? 'Organisatorisch' : 'Physisch'}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">{control.name}</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">{control.description}</p>
|
||||
</div>
|
||||
<select
|
||||
value={control.implementationStatus}
|
||||
onChange={(e) => onStatusChange(e.target.value as ImplementationStatus)}
|
||||
className={`px-3 py-1 text-sm rounded-full border ${statusColors[control.displayStatus]}`}
|
||||
>
|
||||
<option value="NOT_IMPLEMENTED">Nicht implementiert</option>
|
||||
<option value="PARTIAL">Teilweise</option>
|
||||
<option value="IMPLEMENTED">Implementiert</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<div
|
||||
className="flex items-center justify-between text-sm mb-1 cursor-pointer"
|
||||
onClick={() => setShowEffectivenessSlider(!showEffectivenessSlider)}
|
||||
>
|
||||
<span className="text-gray-500">Wirksamkeit</span>
|
||||
<span className="font-medium">{control.effectivenessPercent}%</span>
|
||||
</div>
|
||||
<div className="h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${
|
||||
control.effectivenessPercent >= 80 ? 'bg-green-500' :
|
||||
control.effectivenessPercent >= 50 ? 'bg-yellow-500' : 'bg-red-500'
|
||||
}`}
|
||||
style={{ width: `${control.effectivenessPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
{showEffectivenessSlider && (
|
||||
<div className="mt-2">
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={control.effectivenessPercent}
|
||||
onChange={(e) => onEffectivenessChange(Number(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between text-sm">
|
||||
<div className="text-gray-500">
|
||||
<span>Verantwortlich: </span>
|
||||
<span className="font-medium text-gray-700">{control.owner || 'Nicht zugewiesen'}</span>
|
||||
</div>
|
||||
<div className="text-gray-500">
|
||||
Letzte Pruefung: {control.lastReview.toLocaleDateString('de-DE')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{control.linkedRequirements.slice(0, 3).map(req => (
|
||||
<span key={req} className="px-2 py-0.5 text-xs bg-gray-100 text-gray-600 rounded">
|
||||
{req}
|
||||
</span>
|
||||
))}
|
||||
{control.linkedRequirements.length > 3 && (
|
||||
<span className="px-2 py-0.5 text-xs bg-gray-100 text-gray-600 rounded">
|
||||
+{control.linkedRequirements.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className={`px-3 py-1 text-xs rounded-full ${
|
||||
control.displayStatus === 'implemented' ? 'bg-green-100 text-green-700' :
|
||||
control.displayStatus === 'partial' ? 'bg-yellow-100 text-yellow-700' :
|
||||
control.displayStatus === 'planned' ? 'bg-blue-100 text-blue-700' : 'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
{statusLabels[control.displayStatus]}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN PAGE
|
||||
// =============================================================================
|
||||
|
||||
export default function ControlsPage() {
|
||||
const { state, dispatch } = useSDK()
|
||||
const [filter, setFilter] = useState<string>('all')
|
||||
|
||||
// Track effectiveness locally as it's not in the SDK state type
|
||||
const [effectivenessMap, setEffectivenessMap] = useState<Record<string, number>>({})
|
||||
|
||||
// Load controls based on requirements when requirements exist
|
||||
useEffect(() => {
|
||||
if (state.requirements.length > 0 && state.controls.length === 0) {
|
||||
// Add relevant controls based on requirements
|
||||
const relevantControls = controlTemplates.filter(c =>
|
||||
c.linkedRequirements.some(reqId => state.requirements.some(r => r.id === reqId))
|
||||
)
|
||||
|
||||
relevantControls.forEach(ctrl => {
|
||||
const sdkControl: SDKControl = {
|
||||
id: ctrl.id,
|
||||
name: ctrl.name,
|
||||
description: ctrl.description,
|
||||
type: ctrl.type,
|
||||
category: ctrl.category,
|
||||
implementationStatus: 'NOT_IMPLEMENTED',
|
||||
effectiveness: 'LOW',
|
||||
evidence: [],
|
||||
owner: ctrl.owner,
|
||||
dueDate: null,
|
||||
}
|
||||
dispatch({ type: 'ADD_CONTROL', payload: sdkControl })
|
||||
})
|
||||
}
|
||||
}, [state.requirements, state.controls.length, dispatch])
|
||||
|
||||
// Convert SDK controls to display controls
|
||||
const displayControls: DisplayControl[] = state.controls.map(ctrl => {
|
||||
const template = controlTemplates.find(t => t.id === ctrl.id)
|
||||
const effectivenessPercent = effectivenessMap[ctrl.id] ??
|
||||
(ctrl.implementationStatus === 'IMPLEMENTED' ? 85 :
|
||||
ctrl.implementationStatus === 'PARTIAL' ? 50 : 0)
|
||||
|
||||
return {
|
||||
id: ctrl.id,
|
||||
name: ctrl.name,
|
||||
description: ctrl.description,
|
||||
type: ctrl.type,
|
||||
category: ctrl.category,
|
||||
implementationStatus: ctrl.implementationStatus,
|
||||
evidence: ctrl.evidence,
|
||||
owner: ctrl.owner,
|
||||
dueDate: ctrl.dueDate,
|
||||
code: template?.code || ctrl.id,
|
||||
displayType: template?.displayType || 'preventive',
|
||||
displayCategory: mapControlTypeToDisplay(ctrl.type),
|
||||
displayStatus: mapStatusToDisplay(ctrl.implementationStatus),
|
||||
effectivenessPercent,
|
||||
linkedRequirements: template?.linkedRequirements || [],
|
||||
lastReview: new Date(),
|
||||
}
|
||||
})
|
||||
|
||||
const filteredControls = filter === 'all'
|
||||
? displayControls
|
||||
: displayControls.filter(c =>
|
||||
c.displayStatus === filter ||
|
||||
c.displayType === filter ||
|
||||
c.displayCategory === filter
|
||||
)
|
||||
|
||||
const implementedCount = displayControls.filter(c => c.displayStatus === 'implemented').length
|
||||
const avgEffectiveness = displayControls.length > 0
|
||||
? Math.round(displayControls.reduce((sum, c) => sum + c.effectivenessPercent, 0) / displayControls.length)
|
||||
: 0
|
||||
const partialCount = displayControls.filter(c => c.displayStatus === 'partial').length
|
||||
|
||||
const handleStatusChange = (controlId: string, status: ImplementationStatus) => {
|
||||
dispatch({
|
||||
type: 'UPDATE_CONTROL',
|
||||
payload: { id: controlId, data: { implementationStatus: status } },
|
||||
})
|
||||
}
|
||||
|
||||
const handleEffectivenessChange = (controlId: string, effectiveness: number) => {
|
||||
setEffectivenessMap(prev => ({ ...prev, [controlId]: effectiveness }))
|
||||
}
|
||||
|
||||
const stepInfo = STEP_EXPLANATIONS['controls']
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Step Header */}
|
||||
<StepHeader
|
||||
stepId="controls"
|
||||
title={stepInfo.title}
|
||||
description={stepInfo.description}
|
||||
explanation={stepInfo.explanation}
|
||||
tips={stepInfo.tips}
|
||||
>
|
||||
<button 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>
|
||||
Kontrolle hinzufuegen
|
||||
</button>
|
||||
</StepHeader>
|
||||
|
||||
{/* Requirements Alert */}
|
||||
{state.requirements.length === 0 && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<svg className="w-5 h-5 text-amber-600 mt-0.5" 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>
|
||||
<h4 className="font-medium text-amber-800">Keine Anforderungen definiert</h4>
|
||||
<p className="text-sm text-amber-700 mt-1">
|
||||
Bitte definieren Sie zuerst Anforderungen, um die zugehoerigen Kontrollen zu laden.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="text-sm text-gray-500">Gesamt</div>
|
||||
<div className="text-3xl font-bold text-gray-900">{displayControls.length}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-green-200 p-6">
|
||||
<div className="text-sm text-green-600">Implementiert</div>
|
||||
<div className="text-3xl font-bold text-green-600">{implementedCount}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-purple-200 p-6">
|
||||
<div className="text-sm text-purple-600">Durchschn. Wirksamkeit</div>
|
||||
<div className="text-3xl font-bold text-purple-600">{avgEffectiveness}%</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-yellow-200 p-6">
|
||||
<div className="text-sm text-yellow-600">Teilweise</div>
|
||||
<div className="text-3xl font-bold text-yellow-600">{partialCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm text-gray-500">Filter:</span>
|
||||
{['all', 'implemented', 'partial', 'not-implemented', 'technical', 'organizational', 'preventive', 'detective'].map(f => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
className={`px-3 py-1 text-sm rounded-full transition-colors ${
|
||||
filter === f
|
||||
? 'bg-purple-600 text-white'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{f === 'all' ? 'Alle' :
|
||||
f === 'implemented' ? 'Implementiert' :
|
||||
f === 'partial' ? 'Teilweise' :
|
||||
f === 'not-implemented' ? 'Offen' :
|
||||
f === 'technical' ? 'Technisch' :
|
||||
f === 'organizational' ? 'Organisatorisch' :
|
||||
f === 'preventive' ? 'Praeventiv' : 'Detektiv'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Controls List */}
|
||||
<div className="space-y-4">
|
||||
{filteredControls.map(control => (
|
||||
<ControlCard
|
||||
key={control.id}
|
||||
control={control}
|
||||
onStatusChange={(status) => handleStatusChange(control.id, status)}
|
||||
onEffectivenessChange={(effectiveness) => handleEffectivenessChange(control.id, effectiveness)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredControls.length === 0 && state.requirements.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-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>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Keine Kontrollen gefunden</h3>
|
||||
<p className="mt-2 text-gray-500">Passen Sie den Filter an oder fuegen Sie neue Kontrollen hinzu.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,408 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { useSDK } from '@/lib/sdk'
|
||||
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
interface CookieCategory {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
required: boolean
|
||||
enabled: boolean
|
||||
cookies: Cookie[]
|
||||
}
|
||||
|
||||
interface Cookie {
|
||||
name: string
|
||||
provider: string
|
||||
purpose: string
|
||||
expiry: string
|
||||
type: 'first-party' | 'third-party'
|
||||
}
|
||||
|
||||
interface BannerConfig {
|
||||
position: 'bottom' | 'top' | 'center'
|
||||
style: 'bar' | 'popup' | 'modal'
|
||||
primaryColor: string
|
||||
showDeclineAll: boolean
|
||||
showSettings: boolean
|
||||
blockScripts: boolean
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MOCK DATA
|
||||
// =============================================================================
|
||||
|
||||
const mockCategories: CookieCategory[] = [
|
||||
{
|
||||
id: 'necessary',
|
||||
name: 'Notwendig',
|
||||
description: 'Diese Cookies sind fuer die Grundfunktionen der Website erforderlich.',
|
||||
required: true,
|
||||
enabled: true,
|
||||
cookies: [
|
||||
{ name: 'session_id', provider: 'Eigene', purpose: 'Session-Verwaltung', expiry: 'Session', type: 'first-party' },
|
||||
{ name: 'csrf_token', provider: 'Eigene', purpose: 'Sicherheit', expiry: 'Session', type: 'first-party' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'analytics',
|
||||
name: 'Analyse',
|
||||
description: 'Diese Cookies helfen uns, die Nutzung der Website zu verstehen.',
|
||||
required: false,
|
||||
enabled: true,
|
||||
cookies: [
|
||||
{ name: '_ga', provider: 'Google Analytics', purpose: 'Nutzeranalyse', expiry: '2 Jahre', type: 'third-party' },
|
||||
{ name: '_gid', provider: 'Google Analytics', purpose: 'Nutzeranalyse', expiry: '24 Stunden', type: 'third-party' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'marketing',
|
||||
name: 'Marketing',
|
||||
description: 'Diese Cookies werden fuer personalisierte Werbung verwendet.',
|
||||
required: false,
|
||||
enabled: false,
|
||||
cookies: [
|
||||
{ name: '_fbp', provider: 'Meta (Facebook)', purpose: 'Werbung', expiry: '3 Monate', type: 'third-party' },
|
||||
{ name: 'IDE', provider: 'Google Ads', purpose: 'Werbung', expiry: '1 Jahr', type: 'third-party' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'preferences',
|
||||
name: 'Praeferenzen',
|
||||
description: 'Diese Cookies speichern Ihre Einstellungen und Praeferenzen.',
|
||||
required: false,
|
||||
enabled: true,
|
||||
cookies: [
|
||||
{ name: 'language', provider: 'Eigene', purpose: 'Spracheinstellung', expiry: '1 Jahr', type: 'first-party' },
|
||||
{ name: 'theme', provider: 'Eigene', purpose: 'Design-Einstellung', expiry: '1 Jahr', type: 'first-party' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const defaultConfig: BannerConfig = {
|
||||
position: 'bottom',
|
||||
style: 'bar',
|
||||
primaryColor: '#6366f1',
|
||||
showDeclineAll: true,
|
||||
showSettings: true,
|
||||
blockScripts: true,
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// COMPONENTS
|
||||
// =============================================================================
|
||||
|
||||
function BannerPreview({ config, categories }: { config: BannerConfig; categories: CookieCategory[] }) {
|
||||
return (
|
||||
<div className="relative bg-gray-100 rounded-xl p-8 min-h-64 flex items-end justify-center">
|
||||
<div className="absolute inset-0 flex items-center justify-center text-gray-400 text-sm">
|
||||
Website-Vorschau
|
||||
</div>
|
||||
<div
|
||||
className={`w-full max-w-2xl bg-white rounded-xl shadow-xl p-6 border-2 ${
|
||||
config.position === 'center' ? 'absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2' : ''
|
||||
}`}
|
||||
style={{ borderColor: config.primaryColor }}
|
||||
>
|
||||
<h4 className="font-semibold text-gray-900">Wir verwenden Cookies</h4>
|
||||
<p className="text-sm text-gray-600 mt-2">
|
||||
Wir nutzen Cookies, um Ihnen die bestmoegliche Nutzung unserer Website zu ermoeglichen.
|
||||
</p>
|
||||
<div className="mt-4 flex items-center gap-3">
|
||||
<button
|
||||
className="px-4 py-2 rounded-lg text-white text-sm font-medium"
|
||||
style={{ backgroundColor: config.primaryColor }}
|
||||
>
|
||||
Alle akzeptieren
|
||||
</button>
|
||||
{config.showDeclineAll && (
|
||||
<button className="px-4 py-2 rounded-lg border border-gray-300 text-gray-700 text-sm font-medium">
|
||||
Alle ablehnen
|
||||
</button>
|
||||
)}
|
||||
{config.showSettings && (
|
||||
<button className="px-4 py-2 text-sm text-gray-600 hover:underline">
|
||||
Einstellungen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CategoryCard({
|
||||
category,
|
||||
onToggle,
|
||||
}: {
|
||||
category: CookieCategory
|
||||
onToggle: (enabled: boolean) => void
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<div className="p-4 flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="font-semibold text-gray-900">{category.name}</h4>
|
||||
{category.required && (
|
||||
<span className="px-2 py-0.5 text-xs bg-gray-100 text-gray-500 rounded">Erforderlich</span>
|
||||
)}
|
||||
<span className="px-2 py-0.5 text-xs bg-blue-50 text-blue-600 rounded">
|
||||
{category.cookies.length} Cookies
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-1">{category.description}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="text-sm text-purple-600 hover:underline"
|
||||
>
|
||||
{expanded ? 'Ausblenden' : 'Details'}
|
||||
</button>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={category.enabled}
|
||||
onChange={(e) => onToggle(e.target.checked)}
|
||||
disabled={category.required}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className={`w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-purple-100 rounded-full peer ${
|
||||
category.enabled ? 'peer-checked:bg-purple-600' : ''
|
||||
} peer-disabled:opacity-50 after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all ${
|
||||
category.enabled ? 'after:translate-x-full' : ''
|
||||
}`} />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="border-t border-gray-100 p-4 bg-gray-50">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-gray-500">
|
||||
<th className="pb-2">Cookie</th>
|
||||
<th className="pb-2">Anbieter</th>
|
||||
<th className="pb-2">Zweck</th>
|
||||
<th className="pb-2">Ablauf</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-700">
|
||||
{category.cookies.map(cookie => (
|
||||
<tr key={cookie.name}>
|
||||
<td className="py-1 font-mono text-xs">{cookie.name}</td>
|
||||
<td className="py-1">{cookie.provider}</td>
|
||||
<td className="py-1">{cookie.purpose}</td>
|
||||
<td className="py-1">{cookie.expiry}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN PAGE
|
||||
// =============================================================================
|
||||
|
||||
export default function CookieBannerPage() {
|
||||
const { state } = useSDK()
|
||||
const [categories, setCategories] = useState<CookieCategory[]>(mockCategories)
|
||||
const [config, setConfig] = useState<BannerConfig>(defaultConfig)
|
||||
|
||||
const handleCategoryToggle = (categoryId: string, enabled: boolean) => {
|
||||
setCategories(prev =>
|
||||
prev.map(cat => cat.id === categoryId ? { ...cat, enabled } : cat)
|
||||
)
|
||||
}
|
||||
|
||||
const totalCookies = categories.reduce((sum, cat) => sum + cat.cookies.length, 0)
|
||||
const thirdPartyCookies = categories.reduce(
|
||||
(sum, cat) => sum + cat.cookies.filter(c => c.type === 'third-party').length,
|
||||
0
|
||||
)
|
||||
|
||||
const stepInfo = STEP_EXPLANATIONS['cookie-banner']
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Step Header */}
|
||||
<StepHeader
|
||||
stepId="cookie-banner"
|
||||
title={stepInfo.title}
|
||||
description={stepInfo.description}
|
||||
explanation={stepInfo.explanation}
|
||||
tips={stepInfo.tips}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
Code exportieren
|
||||
</button>
|
||||
<button className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors">
|
||||
Veroeffentlichen
|
||||
</button>
|
||||
</div>
|
||||
</StepHeader>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="text-sm text-gray-500">Kategorien</div>
|
||||
<div className="text-3xl font-bold text-gray-900">{categories.length}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-blue-200 p-6">
|
||||
<div className="text-sm text-blue-600">Cookies gesamt</div>
|
||||
<div className="text-3xl font-bold text-blue-600">{totalCookies}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-orange-200 p-6">
|
||||
<div className="text-sm text-orange-600">Third-Party</div>
|
||||
<div className="text-3xl font-bold text-orange-600">{thirdPartyCookies}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-green-200 p-6">
|
||||
<div className="text-sm text-green-600">Aktive Kategorien</div>
|
||||
<div className="text-3xl font-bold text-green-600">
|
||||
{categories.filter(c => c.enabled).length}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Banner-Vorschau</h3>
|
||||
<BannerPreview config={config} categories={categories} />
|
||||
</div>
|
||||
|
||||
{/* Configuration */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Banner-Einstellungen</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Position</label>
|
||||
<select
|
||||
value={config.position}
|
||||
onChange={(e) => setConfig({ ...config, position: e.target.value as any })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
>
|
||||
<option value="bottom">Unten</option>
|
||||
<option value="top">Oben</option>
|
||||
<option value="center">Zentriert</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Stil</label>
|
||||
<select
|
||||
value={config.style}
|
||||
onChange={(e) => setConfig({ ...config, style: e.target.value as any })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
>
|
||||
<option value="bar">Balken</option>
|
||||
<option value="popup">Popup</option>
|
||||
<option value="modal">Modal</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Primaerfarbe</label>
|
||||
<input
|
||||
type="color"
|
||||
value={config.primaryColor}
|
||||
onChange={(e) => setConfig({ ...config, primaryColor: e.target.value })}
|
||||
className="w-full h-10 rounded-lg cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.showDeclineAll}
|
||||
onChange={(e) => setConfig({ ...config, showDeclineAll: e.target.checked })}
|
||||
className="w-4 h-4 text-purple-600"
|
||||
/>
|
||||
<span className="text-sm">"Alle ablehnen" anzeigen</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.showSettings}
|
||||
onChange={(e) => setConfig({ ...config, showSettings: e.target.checked })}
|
||||
className="w-4 h-4 text-purple-600"
|
||||
/>
|
||||
<span className="text-sm">Einstellungen-Link anzeigen</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.blockScripts}
|
||||
onChange={(e) => setConfig({ ...config, blockScripts: e.target.checked })}
|
||||
className="w-4 h-4 text-purple-600"
|
||||
/>
|
||||
<span className="text-sm">Skripte vor Einwilligung blockieren</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Texte anpassen</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Ueberschrift</label>
|
||||
<input
|
||||
type="text"
|
||||
defaultValue="Wir verwenden Cookies"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
defaultValue="Wir nutzen Cookies, um Ihnen die bestmoegliche Nutzung unserer Website zu ermoeglichen."
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Link zur Datenschutzerklaerung</label>
|
||||
<input
|
||||
type="text"
|
||||
defaultValue="/datenschutz"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cookie Categories */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-semibold text-gray-900">Cookie-Kategorien</h3>
|
||||
<button className="text-sm text-purple-600 hover:text-purple-700">
|
||||
+ Kategorie hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{categories.map(category => (
|
||||
<CategoryCard
|
||||
key={category.id}
|
||||
category={category}
|
||||
onToggle={(enabled) => handleCategoryToggle(category.id, enabled)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,839 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
interface CrawlSource {
|
||||
id: string
|
||||
name: string
|
||||
source_type: string
|
||||
path: string
|
||||
file_extensions: string[]
|
||||
max_depth: number
|
||||
exclude_patterns: string[]
|
||||
enabled: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface CrawlJob {
|
||||
id: string
|
||||
source_id: string
|
||||
source_name?: string
|
||||
status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'
|
||||
job_type: 'full' | 'delta'
|
||||
files_found: number
|
||||
files_processed: number
|
||||
files_new: number
|
||||
files_changed: number
|
||||
files_skipped: number
|
||||
files_error: number
|
||||
error_message?: string
|
||||
started_at?: string
|
||||
completed_at?: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface CrawlDocument {
|
||||
id: string
|
||||
file_name: string
|
||||
file_extension: string
|
||||
file_size_bytes: number
|
||||
classification: string | null
|
||||
classification_confidence: number | null
|
||||
classification_corrected: boolean
|
||||
extraction_status: string
|
||||
archived: boolean
|
||||
ipfs_cid: string | null
|
||||
first_seen_at: string
|
||||
last_seen_at: string
|
||||
version_count: number
|
||||
source_name?: string
|
||||
}
|
||||
|
||||
interface OnboardingReport {
|
||||
id: string
|
||||
total_documents_found: number
|
||||
classification_breakdown: Record<string, number>
|
||||
gaps: GapItem[]
|
||||
compliance_score: number
|
||||
gap_summary?: { critical: number; high: number; medium: number }
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface GapItem {
|
||||
id: string
|
||||
category: string
|
||||
description: string
|
||||
severity: 'CRITICAL' | 'HIGH' | 'MEDIUM'
|
||||
regulation: string
|
||||
requiredAction: string
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// API HELPERS
|
||||
// =============================================================================
|
||||
|
||||
const TENANT_ID = '00000000-0000-0000-0000-000000000001' // Default tenant
|
||||
|
||||
async function api(path: string, options: RequestInit = {}) {
|
||||
const res = await fetch(`/api/sdk/v1/crawler/${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Tenant-ID': TENANT_ID,
|
||||
...options.headers,
|
||||
},
|
||||
})
|
||||
if (res.status === 204) return null
|
||||
return res.json()
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CLASSIFICATION LABELS
|
||||
// =============================================================================
|
||||
|
||||
const CLASSIFICATION_LABELS: Record<string, { label: string; color: string }> = {
|
||||
VVT: { label: 'VVT', color: 'bg-blue-100 text-blue-700' },
|
||||
TOM: { label: 'TOM', color: 'bg-green-100 text-green-700' },
|
||||
DSE: { label: 'DSE', color: 'bg-purple-100 text-purple-700' },
|
||||
AVV: { label: 'AVV', color: 'bg-orange-100 text-orange-700' },
|
||||
DSFA: { label: 'DSFA', color: 'bg-red-100 text-red-700' },
|
||||
Loeschkonzept: { label: 'Loeschkonzept', color: 'bg-yellow-100 text-yellow-700' },
|
||||
Einwilligung: { label: 'Einwilligung', color: 'bg-pink-100 text-pink-700' },
|
||||
Vertrag: { label: 'Vertrag', color: 'bg-indigo-100 text-indigo-700' },
|
||||
Richtlinie: { label: 'Richtlinie', color: 'bg-teal-100 text-teal-700' },
|
||||
Schulungsnachweis: { label: 'Schulung', color: 'bg-cyan-100 text-cyan-700' },
|
||||
Sonstiges: { label: 'Sonstiges', color: 'bg-gray-100 text-gray-700' },
|
||||
}
|
||||
|
||||
const ALL_CLASSIFICATIONS = Object.keys(CLASSIFICATION_LABELS)
|
||||
|
||||
// =============================================================================
|
||||
// TAB: QUELLEN (Sources)
|
||||
// =============================================================================
|
||||
|
||||
function SourcesTab() {
|
||||
const [sources, setSources] = useState<CrawlSource[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [formName, setFormName] = useState('')
|
||||
const [formPath, setFormPath] = useState('')
|
||||
const [testResult, setTestResult] = useState<Record<string, string>>({})
|
||||
|
||||
const loadSources = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await api('sources')
|
||||
setSources(data || [])
|
||||
} catch { /* ignore */ }
|
||||
setLoading(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => { loadSources() }, [loadSources])
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!formName || !formPath) return
|
||||
await api('sources', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name: formName, path: formPath }),
|
||||
})
|
||||
setFormName('')
|
||||
setFormPath('')
|
||||
setShowForm(false)
|
||||
loadSources()
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
await api(`sources/${id}`, { method: 'DELETE' })
|
||||
loadSources()
|
||||
}
|
||||
|
||||
const handleToggle = async (source: CrawlSource) => {
|
||||
await api(`sources/${source.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ enabled: !source.enabled }),
|
||||
})
|
||||
loadSources()
|
||||
}
|
||||
|
||||
const handleTest = async (id: string) => {
|
||||
setTestResult(prev => ({ ...prev, [id]: 'testing...' }))
|
||||
const result = await api(`sources/${id}/test`, { method: 'POST' })
|
||||
setTestResult(prev => ({ ...prev, [id]: result?.message || 'Fehler' }))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Crawl-Quellen</h2>
|
||||
<button
|
||||
onClick={() => setShowForm(!showForm)}
|
||||
className="px-4 py-2 bg-purple-600 text-white text-sm font-medium rounded-lg hover:bg-purple-700"
|
||||
>
|
||||
+ Neue Quelle
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
|
||||
<input
|
||||
value={formName}
|
||||
onChange={e => setFormName(e.target.value)}
|
||||
placeholder="z.B. Compliance-Ordner"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Pfad (relativ zu /data/crawl)</label>
|
||||
<input
|
||||
value={formPath}
|
||||
onChange={e => setFormPath(e.target.value)}
|
||||
placeholder="z.B. compliance-docs"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={handleCreate} className="px-4 py-2 bg-green-600 text-white text-sm rounded-lg hover:bg-green-700">
|
||||
Erstellen
|
||||
</button>
|
||||
<button onClick={() => setShowForm(false)} className="px-4 py-2 text-gray-600 text-sm hover:text-gray-800">
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-gray-500">Laden...</div>
|
||||
) : sources.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500 bg-white rounded-xl border border-gray-200">
|
||||
<p className="text-lg font-medium">Keine Quellen konfiguriert</p>
|
||||
<p className="text-sm mt-1">Erstellen Sie eine Crawl-Quelle um Dokumente zu scannen.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{sources.map(s => (
|
||||
<div key={s.id} className="bg-white rounded-xl border border-gray-200 p-5 flex items-center gap-4">
|
||||
<div className={`w-3 h-3 rounded-full ${s.enabled ? 'bg-green-500' : 'bg-gray-300'}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-gray-900">{s.name}</div>
|
||||
<div className="text-sm text-gray-500 truncate">{s.path}</div>
|
||||
<div className="text-xs text-gray-400 mt-1">
|
||||
Tiefe: {s.max_depth} | Formate: {(typeof s.file_extensions === 'string' ? JSON.parse(s.file_extensions) : s.file_extensions).join(', ')}
|
||||
</div>
|
||||
</div>
|
||||
{testResult[s.id] && (
|
||||
<span className="text-xs text-gray-500 bg-gray-50 px-2 py-1 rounded">{testResult[s.id]}</span>
|
||||
)}
|
||||
<button onClick={() => handleTest(s.id)} className="text-sm text-blue-600 hover:text-blue-800">Testen</button>
|
||||
<button onClick={() => handleToggle(s)} className="text-sm text-gray-600 hover:text-gray-800">
|
||||
{s.enabled ? 'Deaktivieren' : 'Aktivieren'}
|
||||
</button>
|
||||
<button onClick={() => handleDelete(s.id)} className="text-sm text-red-600 hover:text-red-800">Loeschen</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// TAB: CRAWL-JOBS
|
||||
// =============================================================================
|
||||
|
||||
function JobsTab() {
|
||||
const [jobs, setJobs] = useState<CrawlJob[]>([])
|
||||
const [sources, setSources] = useState<CrawlSource[]>([])
|
||||
const [selectedSource, setSelectedSource] = useState('')
|
||||
const [jobType, setJobType] = useState<'full' | 'delta'>('full')
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [j, s] = await Promise.all([api('jobs'), api('sources')])
|
||||
setJobs(j || [])
|
||||
setSources(s || [])
|
||||
if (!selectedSource && s?.length > 0) setSelectedSource(s[0].id)
|
||||
} catch { /* ignore */ }
|
||||
setLoading(false)
|
||||
}, [selectedSource])
|
||||
|
||||
useEffect(() => { loadData() }, [loadData])
|
||||
|
||||
// Auto-refresh running jobs
|
||||
useEffect(() => {
|
||||
const hasRunning = jobs.some(j => j.status === 'running' || j.status === 'pending')
|
||||
if (!hasRunning) return
|
||||
const interval = setInterval(loadData, 3000)
|
||||
return () => clearInterval(interval)
|
||||
}, [jobs, loadData])
|
||||
|
||||
const handleTrigger = async () => {
|
||||
if (!selectedSource) return
|
||||
await api('jobs', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ source_id: selectedSource, job_type: jobType }),
|
||||
})
|
||||
loadData()
|
||||
}
|
||||
|
||||
const handleCancel = async (id: string) => {
|
||||
await api(`jobs/${id}/cancel`, { method: 'POST' })
|
||||
loadData()
|
||||
}
|
||||
|
||||
const statusColor = (s: string) => {
|
||||
switch (s) {
|
||||
case 'completed': return 'bg-green-100 text-green-700'
|
||||
case 'running': return 'bg-blue-100 text-blue-700'
|
||||
case 'pending': return 'bg-yellow-100 text-yellow-700'
|
||||
case 'failed': return 'bg-red-100 text-red-700'
|
||||
case 'cancelled': return 'bg-gray-100 text-gray-600'
|
||||
default: return 'bg-gray-100 text-gray-700'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Trigger form */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="font-medium text-gray-900 mb-4">Neuen Crawl starten</h3>
|
||||
<div className="flex gap-4 items-end">
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Quelle</label>
|
||||
<select
|
||||
value={selectedSource}
|
||||
onChange={e => setSelectedSource(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||
>
|
||||
{sources.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Typ</label>
|
||||
<select
|
||||
value={jobType}
|
||||
onChange={e => setJobType(e.target.value as 'full' | 'delta')}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||
>
|
||||
<option value="full">Voll-Scan</option>
|
||||
<option value="delta">Delta-Scan</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleTrigger}
|
||||
disabled={!selectedSource}
|
||||
className="px-6 py-2 bg-purple-600 text-white text-sm font-medium rounded-lg hover:bg-purple-700 disabled:opacity-50"
|
||||
>
|
||||
Crawl starten
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Job list */}
|
||||
{loading ? (
|
||||
<div className="text-center py-8 text-gray-500">Laden...</div>
|
||||
) : jobs.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500 bg-white rounded-xl border border-gray-200">
|
||||
Noch keine Crawl-Jobs ausgefuehrt.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{jobs.map(job => (
|
||||
<div key={job.id} className="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded ${statusColor(job.status)}`}>
|
||||
{job.status}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-gray-900">{job.source_name || 'Quelle'}</span>
|
||||
<span className="text-xs text-gray-400">{job.job_type === 'delta' ? 'Delta' : 'Voll'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{(job.status === 'running' || job.status === 'pending') && (
|
||||
<button onClick={() => handleCancel(job.id)} className="text-xs text-red-600 hover:text-red-800">
|
||||
Abbrechen
|
||||
</button>
|
||||
)}
|
||||
<span className="text-xs text-gray-400">
|
||||
{new Date(job.created_at).toLocaleString('de-DE')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
{job.status === 'running' && job.files_found > 0 && (
|
||||
<div className="mb-3">
|
||||
<div className="w-full h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-purple-600 rounded-full transition-all"
|
||||
style={{ width: `${(job.files_processed / job.files_found) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
{job.files_processed} / {job.files_found} Dateien verarbeitet
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-6 gap-2 text-center">
|
||||
<div className="bg-gray-50 rounded-lg p-2">
|
||||
<div className="text-lg font-bold text-gray-900">{job.files_found}</div>
|
||||
<div className="text-xs text-gray-500">Gefunden</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-lg p-2">
|
||||
<div className="text-lg font-bold text-gray-900">{job.files_processed}</div>
|
||||
<div className="text-xs text-gray-500">Verarbeitet</div>
|
||||
</div>
|
||||
<div className="bg-green-50 rounded-lg p-2">
|
||||
<div className="text-lg font-bold text-green-700">{job.files_new}</div>
|
||||
<div className="text-xs text-green-600">Neu</div>
|
||||
</div>
|
||||
<div className="bg-blue-50 rounded-lg p-2">
|
||||
<div className="text-lg font-bold text-blue-700">{job.files_changed}</div>
|
||||
<div className="text-xs text-blue-600">Geaendert</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-lg p-2">
|
||||
<div className="text-lg font-bold text-gray-500">{job.files_skipped}</div>
|
||||
<div className="text-xs text-gray-500">Uebersprungen</div>
|
||||
</div>
|
||||
<div className="bg-red-50 rounded-lg p-2">
|
||||
<div className="text-lg font-bold text-red-700">{job.files_error}</div>
|
||||
<div className="text-xs text-red-600">Fehler</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// TAB: DOKUMENTE
|
||||
// =============================================================================
|
||||
|
||||
function DocumentsTab() {
|
||||
const [docs, setDocs] = useState<CrawlDocument[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [filterClass, setFilterClass] = useState('')
|
||||
const [archiving, setArchiving] = useState<Record<string, boolean>>({})
|
||||
|
||||
const loadDocs = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params = filterClass ? `?classification=${filterClass}` : ''
|
||||
const data = await api(`documents${params}`)
|
||||
setDocs(data?.documents || [])
|
||||
setTotal(data?.total || 0)
|
||||
} catch { /* ignore */ }
|
||||
setLoading(false)
|
||||
}, [filterClass])
|
||||
|
||||
useEffect(() => { loadDocs() }, [loadDocs])
|
||||
|
||||
const handleReclassify = async (docId: string, newClass: string) => {
|
||||
await api(`documents/${docId}/classify`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ classification: newClass }),
|
||||
})
|
||||
loadDocs()
|
||||
}
|
||||
|
||||
const handleArchive = async (docId: string) => {
|
||||
setArchiving(prev => ({ ...prev, [docId]: true }))
|
||||
try {
|
||||
await api(`documents/${docId}/archive`, { method: 'POST' })
|
||||
loadDocs()
|
||||
} catch { /* ignore */ }
|
||||
setArchiving(prev => ({ ...prev, [docId]: false }))
|
||||
}
|
||||
|
||||
const formatSize = (bytes: number) => {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-gray-900">{total} Dokumente</h2>
|
||||
<select
|
||||
value={filterClass}
|
||||
onChange={e => setFilterClass(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||
>
|
||||
<option value="">Alle Kategorien</option>
|
||||
{ALL_CLASSIFICATIONS.map(c => (
|
||||
<option key={c} value={c}>{CLASSIFICATION_LABELS[c]?.label || c}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-gray-500">Laden...</div>
|
||||
) : docs.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500 bg-white rounded-xl border border-gray-200">
|
||||
Keine Dokumente gefunden. Starten Sie zuerst einen Crawl-Job.
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 text-gray-600">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 font-medium">Datei</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Kategorie</th>
|
||||
<th className="text-center px-4 py-3 font-medium">Konfidenz</th>
|
||||
<th className="text-right px-4 py-3 font-medium">Groesse</th>
|
||||
<th className="text-center px-4 py-3 font-medium">Archiv</th>
|
||||
<th className="text-right px-4 py-3 font-medium">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{docs.map(doc => {
|
||||
const cls = CLASSIFICATION_LABELS[doc.classification || ''] || CLASSIFICATION_LABELS['Sonstiges']
|
||||
return (
|
||||
<tr key={doc.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-medium text-gray-900 truncate max-w-xs">{doc.file_name}</div>
|
||||
<div className="text-xs text-gray-400">{doc.source_name}</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<select
|
||||
value={doc.classification || 'Sonstiges'}
|
||||
onChange={e => handleReclassify(doc.id, e.target.value)}
|
||||
className={`px-2 py-1 text-xs font-medium rounded border-0 ${cls.color}`}
|
||||
>
|
||||
{ALL_CLASSIFICATIONS.map(c => (
|
||||
<option key={c} value={c}>{CLASSIFICATION_LABELS[c].label}</option>
|
||||
))}
|
||||
</select>
|
||||
{doc.classification_corrected && (
|
||||
<span className="ml-1 text-xs text-orange-500" title="Manuell korrigiert">*</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
{doc.classification_confidence != null && (
|
||||
<div className="inline-flex items-center gap-1">
|
||||
<div className="w-12 h-1.5 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-purple-500 rounded-full"
|
||||
style={{ width: `${doc.classification_confidence * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">
|
||||
{(doc.classification_confidence * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-gray-500">{formatSize(doc.file_size_bytes)}</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
{doc.archived ? (
|
||||
<span className="text-green-600 text-xs font-medium" title={doc.ipfs_cid || ''}>IPFS</span>
|
||||
) : (
|
||||
<span className="text-gray-400 text-xs">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
{!doc.archived && (
|
||||
<button
|
||||
onClick={() => handleArchive(doc.id)}
|
||||
disabled={archiving[doc.id]}
|
||||
className="text-xs text-purple-600 hover:text-purple-800 disabled:opacity-50"
|
||||
>
|
||||
{archiving[doc.id] ? 'Archiviert...' : 'Archivieren'}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// TAB: ONBOARDING-REPORT
|
||||
// =============================================================================
|
||||
|
||||
function ReportTab() {
|
||||
const [reports, setReports] = useState<OnboardingReport[]>([])
|
||||
const [activeReport, setActiveReport] = useState<OnboardingReport | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [generating, setGenerating] = useState(false)
|
||||
|
||||
const loadReports = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await api('reports')
|
||||
setReports(data || [])
|
||||
if (data?.length > 0 && !activeReport) {
|
||||
const detail = await api(`reports/${data[0].id}`)
|
||||
setActiveReport(detail)
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
setLoading(false)
|
||||
}, [activeReport])
|
||||
|
||||
useEffect(() => { loadReports() }, [loadReports])
|
||||
|
||||
const handleGenerate = async () => {
|
||||
setGenerating(true)
|
||||
try {
|
||||
const result = await api('reports/generate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
setActiveReport(result)
|
||||
loadReports()
|
||||
} catch { /* ignore */ }
|
||||
setGenerating(false)
|
||||
}
|
||||
|
||||
const handleSelectReport = async (id: string) => {
|
||||
const detail = await api(`reports/${id}`)
|
||||
setActiveReport(detail)
|
||||
}
|
||||
|
||||
// Compliance score ring
|
||||
const ComplianceRing = ({ score }: { score: number }) => {
|
||||
const radius = 50
|
||||
const circumference = 2 * Math.PI * radius
|
||||
const offset = circumference - (score / 100) * circumference
|
||||
const color = score >= 75 ? '#16a34a' : score >= 50 ? '#f59e0b' : '#dc2626'
|
||||
|
||||
return (
|
||||
<div className="relative w-36 h-36">
|
||||
<svg className="w-full h-full -rotate-90">
|
||||
<circle cx="68" cy="68" r={radius} fill="none" stroke="#e5e7eb" strokeWidth="8" />
|
||||
<circle
|
||||
cx="68" cy="68" r={radius} fill="none"
|
||||
stroke={color} strokeWidth="8"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
strokeLinecap="round"
|
||||
className="transition-all duration-1000"
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className="text-3xl font-bold" style={{ color }}>{score.toFixed(0)}%</span>
|
||||
<span className="text-xs text-gray-500">Compliance</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Onboarding-Report</h2>
|
||||
<button
|
||||
onClick={handleGenerate}
|
||||
disabled={generating}
|
||||
className="px-4 py-2 bg-purple-600 text-white text-sm font-medium rounded-lg hover:bg-purple-700 disabled:opacity-50"
|
||||
>
|
||||
{generating ? 'Generiere...' : 'Neuen Report erstellen'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Report selector */}
|
||||
{reports.length > 1 && (
|
||||
<div className="flex gap-2 overflow-x-auto pb-2">
|
||||
{reports.map(r => (
|
||||
<button
|
||||
key={r.id}
|
||||
onClick={() => handleSelectReport(r.id)}
|
||||
className={`px-3 py-1.5 text-xs rounded-lg border whitespace-nowrap ${
|
||||
activeReport?.id === r.id
|
||||
? 'bg-purple-50 border-purple-300 text-purple-700'
|
||||
: 'bg-white border-gray-200 text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{new Date(r.created_at).toLocaleString('de-DE')} — {r.compliance_score.toFixed(0)}%
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-gray-500">Laden...</div>
|
||||
) : !activeReport ? (
|
||||
<div className="text-center py-12 text-gray-500 bg-white rounded-xl border border-gray-200">
|
||||
<p className="text-lg font-medium">Kein Report vorhanden</p>
|
||||
<p className="text-sm mt-1">Fuehren Sie zuerst einen Crawl durch und generieren Sie dann einen Report.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Score + Stats */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex items-center gap-8">
|
||||
<ComplianceRing score={activeReport.compliance_score} />
|
||||
<div className="flex-1 grid grid-cols-3 gap-4">
|
||||
<div className="text-center p-4 bg-gray-50 rounded-xl">
|
||||
<div className="text-3xl font-bold text-gray-900">{activeReport.total_documents_found}</div>
|
||||
<div className="text-sm text-gray-500">Dokumente gefunden</div>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-gray-50 rounded-xl">
|
||||
<div className="text-3xl font-bold text-gray-900">
|
||||
{Object.keys(activeReport.classification_breakdown || {}).length}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">Kategorien abgedeckt</div>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-gray-50 rounded-xl">
|
||||
<div className="text-3xl font-bold text-red-600">
|
||||
{(activeReport.gaps || []).length}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">Luecken identifiziert</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Classification breakdown */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="font-medium text-gray-900 mb-4">Dokumenten-Verteilung</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.entries(activeReport.classification_breakdown || {}).map(([cat, count]) => {
|
||||
const cls = CLASSIFICATION_LABELS[cat] || CLASSIFICATION_LABELS['Sonstiges']
|
||||
return (
|
||||
<span key={cat} className={`px-3 py-1.5 text-sm font-medium rounded-lg ${cls.color}`}>
|
||||
{cls.label}: {count as number}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
{Object.keys(activeReport.classification_breakdown || {}).length === 0 && (
|
||||
<span className="text-gray-400 text-sm">Keine Dokumente klassifiziert</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Gap summary */}
|
||||
{activeReport.gap_summary && (
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="text-center p-4 bg-red-50 rounded-xl border border-red-100">
|
||||
<div className="text-3xl font-bold text-red-600">{activeReport.gap_summary.critical}</div>
|
||||
<div className="text-sm text-red-600 font-medium">Kritisch</div>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-orange-50 rounded-xl border border-orange-100">
|
||||
<div className="text-3xl font-bold text-orange-600">{activeReport.gap_summary.high}</div>
|
||||
<div className="text-sm text-orange-600 font-medium">Hoch</div>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-yellow-50 rounded-xl border border-yellow-100">
|
||||
<div className="text-3xl font-bold text-yellow-600">{activeReport.gap_summary.medium}</div>
|
||||
<div className="text-sm text-yellow-600 font-medium">Mittel</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Gap details */}
|
||||
{(activeReport.gaps || []).length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="font-medium text-gray-900 mb-4">Compliance-Luecken</h3>
|
||||
<div className="space-y-3">
|
||||
{activeReport.gaps.map((gap) => (
|
||||
<div
|
||||
key={gap.id}
|
||||
className={`p-4 rounded-lg border-l-4 ${
|
||||
gap.severity === 'CRITICAL'
|
||||
? 'bg-red-50 border-red-500'
|
||||
: gap.severity === 'HIGH'
|
||||
? 'bg-orange-50 border-orange-500'
|
||||
: 'bg-yellow-50 border-yellow-500'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{gap.category}</div>
|
||||
<p className="text-sm text-gray-600 mt-1">{gap.description}</p>
|
||||
</div>
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded ${
|
||||
gap.severity === 'CRITICAL' ? 'bg-red-100 text-red-700'
|
||||
: gap.severity === 'HIGH' ? 'bg-orange-100 text-orange-700'
|
||||
: 'bg-yellow-100 text-yellow-700'
|
||||
}`}>
|
||||
{gap.severity}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-gray-500">
|
||||
Regulierung: {gap.regulation} | Aktion: {gap.requiredAction}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN PAGE
|
||||
// =============================================================================
|
||||
|
||||
type Tab = 'sources' | 'jobs' | 'documents' | 'report'
|
||||
|
||||
export default function DocumentCrawlerPage() {
|
||||
const [activeTab, setActiveTab] = useState<Tab>('sources')
|
||||
|
||||
const tabs: { id: Tab; label: string }[] = [
|
||||
{ id: 'sources', label: 'Quellen' },
|
||||
{ id: 'jobs', label: 'Crawl-Jobs' },
|
||||
{ id: 'documents', label: 'Dokumente' },
|
||||
{ id: 'report', label: 'Onboarding-Report' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Document Crawler & Auto-Onboarding</h1>
|
||||
<p className="mt-1 text-gray-500">
|
||||
Automatisches Scannen von Dateisystemen, KI-Klassifizierung, IPFS-Archivierung und Compliance Gap-Analyse.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-gray-200">
|
||||
<nav className="flex gap-6">
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`pb-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'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
{activeTab === 'sources' && <SourcesTab />}
|
||||
{activeTab === 'jobs' && <JobsTab />}
|
||||
{activeTab === 'documents' && <DocumentsTab />}
|
||||
{activeTab === 'report' && <ReportTab />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,793 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { useSDK } from '@/lib/sdk'
|
||||
import { useEinwilligungen } from '@/lib/sdk/einwilligungen/context'
|
||||
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
||||
import {
|
||||
LegalTemplateResult,
|
||||
TemplateType,
|
||||
Jurisdiction,
|
||||
LicenseType,
|
||||
GeneratedDocument,
|
||||
TEMPLATE_TYPE_LABELS,
|
||||
LICENSE_TYPE_LABELS,
|
||||
JURISDICTION_LABELS,
|
||||
DEFAULT_PLACEHOLDERS,
|
||||
} from '@/lib/sdk/types'
|
||||
import { DataPointsPreview } from './components/DataPointsPreview'
|
||||
import { DocumentValidation } from './components/DocumentValidation'
|
||||
import { generateAllPlaceholders } from '@/lib/sdk/document-generator/datapoint-helpers'
|
||||
|
||||
// =============================================================================
|
||||
// API CLIENT
|
||||
// =============================================================================
|
||||
|
||||
const KLAUSUR_SERVICE_URL = process.env.NEXT_PUBLIC_KLAUSUR_SERVICE_URL || 'http://localhost:8086'
|
||||
|
||||
async function searchTemplates(params: {
|
||||
query: string
|
||||
templateType?: TemplateType
|
||||
licenseTypes?: LicenseType[]
|
||||
language?: 'de' | 'en'
|
||||
jurisdiction?: Jurisdiction
|
||||
limit?: number
|
||||
}): Promise<LegalTemplateResult[]> {
|
||||
const response = await fetch(`${KLAUSUR_SERVICE_URL}/api/v1/admin/templates/search`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
query: params.query,
|
||||
template_type: params.templateType,
|
||||
license_types: params.licenseTypes,
|
||||
language: params.language,
|
||||
jurisdiction: params.jurisdiction,
|
||||
limit: params.limit || 10,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Search failed')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data.map((r: any) => ({
|
||||
id: r.id,
|
||||
score: r.score,
|
||||
text: r.text,
|
||||
documentTitle: r.document_title,
|
||||
templateType: r.template_type,
|
||||
clauseCategory: r.clause_category,
|
||||
language: r.language,
|
||||
jurisdiction: r.jurisdiction,
|
||||
licenseId: r.license_id,
|
||||
licenseName: r.license_name,
|
||||
licenseUrl: r.license_url,
|
||||
attributionRequired: r.attribution_required,
|
||||
attributionText: r.attribution_text,
|
||||
sourceName: r.source_name,
|
||||
sourceUrl: r.source_url,
|
||||
sourceRepo: r.source_repo,
|
||||
placeholders: r.placeholders || [],
|
||||
isCompleteDocument: r.is_complete_document,
|
||||
isModular: r.is_modular,
|
||||
requiresCustomization: r.requires_customization,
|
||||
outputAllowed: r.output_allowed ?? true,
|
||||
modificationAllowed: r.modification_allowed ?? true,
|
||||
distortionProhibited: r.distortion_prohibited ?? false,
|
||||
}))
|
||||
}
|
||||
|
||||
async function getTemplatesStatus(): Promise<any> {
|
||||
const response = await fetch(`${KLAUSUR_SERVICE_URL}/api/v1/admin/templates/status`)
|
||||
if (!response.ok) return null
|
||||
return response.json()
|
||||
}
|
||||
|
||||
async function getSources(): Promise<any[]> {
|
||||
const response = await fetch(`${KLAUSUR_SERVICE_URL}/api/v1/admin/templates/sources`)
|
||||
if (!response.ok) return []
|
||||
const data = await response.json()
|
||||
return data.sources || []
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// COMPONENTS
|
||||
// =============================================================================
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const colors: Record<string, string> = {
|
||||
ready: 'bg-green-100 text-green-700',
|
||||
empty: 'bg-yellow-100 text-yellow-700',
|
||||
error: 'bg-red-100 text-red-700',
|
||||
running: 'bg-blue-100 text-blue-700',
|
||||
}
|
||||
return (
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${colors[status] || 'bg-gray-100 text-gray-700'}`}>
|
||||
{status}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function LicenseBadge({ licenseId, small = false }: { licenseId: LicenseType | null; small?: boolean }) {
|
||||
if (!licenseId) return null
|
||||
|
||||
const colors: Record<LicenseType, string> = {
|
||||
public_domain: 'bg-green-100 text-green-700 border-green-200',
|
||||
cc0: 'bg-green-100 text-green-700 border-green-200',
|
||||
unlicense: 'bg-green-100 text-green-700 border-green-200',
|
||||
mit: 'bg-blue-100 text-blue-700 border-blue-200',
|
||||
cc_by_4: 'bg-purple-100 text-purple-700 border-purple-200',
|
||||
reuse_notice: 'bg-orange-100 text-orange-700 border-orange-200',
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={`${small ? 'px-1.5 py-0.5 text-[10px]' : 'px-2 py-1 text-xs'} rounded border ${colors[licenseId]}`}>
|
||||
{LICENSE_TYPE_LABELS[licenseId] || licenseId}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function TemplateCard({
|
||||
template,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
template: LegalTemplateResult
|
||||
selected: boolean
|
||||
onSelect: () => void
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
onClick={onSelect}
|
||||
className={`p-4 rounded-xl border-2 cursor-pointer transition-all ${
|
||||
selected
|
||||
? 'border-purple-500 bg-purple-50'
|
||||
: 'border-gray-200 hover:border-purple-300 bg-white'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-medium text-gray-900">
|
||||
{template.documentTitle || 'Untitled'}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 bg-gray-100 px-2 py-0.5 rounded">
|
||||
{template.score.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1 flex-wrap">
|
||||
{template.templateType && (
|
||||
<span className="text-xs text-purple-600 bg-purple-100 px-2 py-0.5 rounded">
|
||||
{TEMPLATE_TYPE_LABELS[template.templateType as TemplateType] || template.templateType}
|
||||
</span>
|
||||
)}
|
||||
<LicenseBadge licenseId={template.licenseId as LicenseType} small />
|
||||
<span className="text-xs text-gray-500 uppercase">
|
||||
{template.language}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected}
|
||||
onChange={onSelect}
|
||||
className="w-5 h-5 text-purple-600 rounded border-gray-300"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-600 line-clamp-3 mt-2">
|
||||
{template.text}
|
||||
</p>
|
||||
|
||||
{template.attributionRequired && template.attributionText && (
|
||||
<div className="mt-2 text-xs text-orange-600 bg-orange-50 p-2 rounded">
|
||||
Attribution: {template.attributionText}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{template.placeholders && template.placeholders.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{template.placeholders.slice(0, 5).map((p, i) => (
|
||||
<span key={i} className="text-xs bg-blue-100 text-blue-700 px-1.5 py-0.5 rounded">
|
||||
{p}
|
||||
</span>
|
||||
))}
|
||||
{template.placeholders.length > 5 && (
|
||||
<span className="text-xs text-gray-500">
|
||||
+{template.placeholders.length - 5} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3 text-xs text-gray-500">
|
||||
Source: {template.sourceName}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PlaceholderEditor({
|
||||
placeholders,
|
||||
values,
|
||||
onChange,
|
||||
}: {
|
||||
placeholders: string[]
|
||||
values: Record<string, string>
|
||||
onChange: (key: string, value: string) => void
|
||||
}) {
|
||||
if (placeholders.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="bg-blue-50 rounded-xl p-4 border border-blue-200">
|
||||
<h4 className="font-medium text-blue-900 mb-3">Platzhalter ausfuellen</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{placeholders.map((placeholder) => (
|
||||
<div key={placeholder}>
|
||||
<label className="block text-sm text-blue-700 mb-1">{placeholder}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={values[placeholder] || ''}
|
||||
onChange={(e) => onChange(placeholder, e.target.value)}
|
||||
placeholder={`Wert fuer ${placeholder}`}
|
||||
className="w-full px-3 py-2 border border-blue-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AttributionFooter({ templates }: { templates: LegalTemplateResult[] }) {
|
||||
const attributionTemplates = templates.filter((t) => t.attributionRequired)
|
||||
if (attributionTemplates.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="bg-gray-50 rounded-xl p-4 border border-gray-200">
|
||||
<h4 className="font-medium text-gray-900 mb-2">Quellenangaben (werden automatisch hinzugefuegt)</h4>
|
||||
<div className="text-sm text-gray-600 space-y-1">
|
||||
<p>Dieses Dokument wurde unter Verwendung folgender Quellen erstellt:</p>
|
||||
<ul className="list-disc list-inside ml-2">
|
||||
{attributionTemplates.map((t, i) => (
|
||||
<li key={i}>
|
||||
{t.attributionText || `${t.sourceName} (${t.licenseName})`}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DocumentPreview({
|
||||
content,
|
||||
placeholders,
|
||||
}: {
|
||||
content: string
|
||||
placeholders: Record<string, string>
|
||||
}) {
|
||||
// Replace placeholders in content
|
||||
let processedContent = content
|
||||
for (const [key, value] of Object.entries(placeholders)) {
|
||||
if (value) {
|
||||
processedContent = processedContent.replace(new RegExp(key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), value)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6 prose prose-sm max-w-none">
|
||||
<div className="whitespace-pre-wrap">{processedContent}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN PAGE
|
||||
// =============================================================================
|
||||
|
||||
export default function DocumentGeneratorPage() {
|
||||
const { state } = useSDK()
|
||||
const { selectedDataPointsData } = useEinwilligungen()
|
||||
|
||||
// Status state
|
||||
const [status, setStatus] = useState<any>(null)
|
||||
const [sources, setSources] = useState<any[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
// Search state
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [selectedType, setSelectedType] = useState<TemplateType | ''>('')
|
||||
const [selectedLanguage, setSelectedLanguage] = useState<'de' | 'en' | ''>('')
|
||||
const [selectedJurisdiction, setSelectedJurisdiction] = useState<Jurisdiction | ''>('')
|
||||
const [searchResults, setSearchResults] = useState<LegalTemplateResult[]>([])
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
|
||||
// Selection state
|
||||
const [selectedTemplates, setSelectedTemplates] = useState<string[]>([])
|
||||
|
||||
// Editor state
|
||||
const [placeholderValues, setPlaceholderValues] = useState<Record<string, string>>({})
|
||||
const [activeTab, setActiveTab] = useState<'search' | 'compose' | 'preview'>('search')
|
||||
|
||||
// Load initial status
|
||||
useEffect(() => {
|
||||
async function loadStatus() {
|
||||
try {
|
||||
const [statusData, sourcesData] = await Promise.all([
|
||||
getTemplatesStatus(),
|
||||
getSources(),
|
||||
])
|
||||
setStatus(statusData)
|
||||
setSources(sourcesData)
|
||||
} catch (error) {
|
||||
console.error('Failed to load status:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
loadStatus()
|
||||
}, [])
|
||||
|
||||
// Pre-fill placeholders from company profile
|
||||
useEffect(() => {
|
||||
if (state?.companyProfile) {
|
||||
const profile = state.companyProfile
|
||||
setPlaceholderValues((prev) => ({
|
||||
...prev,
|
||||
'[COMPANY_NAME]': profile.companyName || '',
|
||||
'[FIRMENNAME]': profile.companyName || '',
|
||||
'[EMAIL]': profile.dpoEmail || '',
|
||||
'[DSB_EMAIL]': profile.dpoEmail || '',
|
||||
'[DPO_NAME]': profile.dpoName || '',
|
||||
'[DSB_NAME]': profile.dpoName || '',
|
||||
}))
|
||||
}
|
||||
}, [state?.companyProfile])
|
||||
|
||||
// Pre-fill placeholders from Einwilligungen data points
|
||||
useEffect(() => {
|
||||
if (selectedDataPointsData && selectedDataPointsData.length > 0) {
|
||||
const einwilligungenPlaceholders = generateAllPlaceholders(selectedDataPointsData, 'de')
|
||||
setPlaceholderValues((prev) => ({
|
||||
...prev,
|
||||
...einwilligungenPlaceholders,
|
||||
}))
|
||||
}
|
||||
}, [selectedDataPointsData])
|
||||
|
||||
// Handler for inserting placeholders from DataPointsPreview
|
||||
const handleInsertPlaceholder = useCallback((placeholder: string) => {
|
||||
// This is a simplified version - in a real editor you would insert at cursor position
|
||||
// For now, we just ensure the placeholder is in the values so it can be replaced
|
||||
if (!placeholderValues[placeholder]) {
|
||||
// The placeholder value will be generated from einwilligungen data
|
||||
const einwilligungenPlaceholders = generateAllPlaceholders(selectedDataPointsData || [], 'de')
|
||||
if (einwilligungenPlaceholders[placeholder as keyof typeof einwilligungenPlaceholders]) {
|
||||
setPlaceholderValues((prev) => ({
|
||||
...prev,
|
||||
[placeholder]: einwilligungenPlaceholders[placeholder as keyof typeof einwilligungenPlaceholders],
|
||||
}))
|
||||
}
|
||||
}
|
||||
}, [placeholderValues, selectedDataPointsData])
|
||||
|
||||
// Search handler
|
||||
const handleSearch = useCallback(async () => {
|
||||
if (!searchQuery.trim()) return
|
||||
|
||||
setIsSearching(true)
|
||||
try {
|
||||
const results = await searchTemplates({
|
||||
query: searchQuery,
|
||||
templateType: selectedType || undefined,
|
||||
language: selectedLanguage || undefined,
|
||||
jurisdiction: selectedJurisdiction || undefined,
|
||||
limit: 20,
|
||||
})
|
||||
setSearchResults(results)
|
||||
} catch (error) {
|
||||
console.error('Search failed:', error)
|
||||
} finally {
|
||||
setIsSearching(false)
|
||||
}
|
||||
}, [searchQuery, selectedType, selectedLanguage, selectedJurisdiction])
|
||||
|
||||
// Toggle template selection
|
||||
const toggleTemplate = (id: string) => {
|
||||
setSelectedTemplates((prev) =>
|
||||
prev.includes(id) ? prev.filter((t) => t !== id) : [...prev, id]
|
||||
)
|
||||
}
|
||||
|
||||
// Get selected template objects
|
||||
const selectedTemplateObjects = searchResults.filter((r) =>
|
||||
selectedTemplates.includes(r.id)
|
||||
)
|
||||
|
||||
// Get all unique placeholders from selected templates
|
||||
const allPlaceholders = Array.from(
|
||||
new Set(selectedTemplateObjects.flatMap((t) => t.placeholders || []))
|
||||
)
|
||||
|
||||
// Combined content from selected templates
|
||||
const combinedContent = selectedTemplateObjects
|
||||
.map((t) => `## ${t.documentTitle || 'Abschnitt'}\n\n${t.text}`)
|
||||
.join('\n\n---\n\n')
|
||||
|
||||
// Step info - using 'consent' as base since document-generator doesn't exist yet
|
||||
const stepInfo = STEP_EXPLANATIONS['consent'] || {
|
||||
title: 'Dokumentengenerator',
|
||||
description: 'Generieren Sie rechtliche Dokumente aus lizenzkonformen Vorlagen',
|
||||
explanation: 'Der Dokumentengenerator nutzt frei lizenzierte Textbausteine um Datenschutzerklaerungen, AGB und andere rechtliche Dokumente zu erstellen.',
|
||||
tips: ['Waehlen Sie passende Vorlagen aus der Suche', 'Fuellen Sie die Platzhalter mit Ihren Unternehmensdaten'],
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Step Header */}
|
||||
<StepHeader
|
||||
stepId="document-generator"
|
||||
title="Dokumentengenerator"
|
||||
description="Generieren Sie rechtliche Dokumente aus lizenzkonformen Vorlagen"
|
||||
explanation={stepInfo.explanation}
|
||||
tips={stepInfo.tips}
|
||||
>
|
||||
<button
|
||||
onClick={() => setActiveTab('compose')}
|
||||
disabled={selectedTemplates.length === 0}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<svg className="w-5 h-5" 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>
|
||||
Dokument erstellen ({selectedTemplates.length})
|
||||
</button>
|
||||
</StepHeader>
|
||||
|
||||
{/* Status Overview */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="text-sm text-gray-500">Collection Status</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<StatusBadge status={status?.stats?.status || 'unknown'} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="text-sm text-gray-500">Indexierte Chunks</div>
|
||||
<div className="text-3xl font-bold text-gray-900">
|
||||
{status?.stats?.points_count || 0}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="text-sm text-gray-500">Aktive Quellen</div>
|
||||
<div className="text-3xl font-bold text-purple-600">
|
||||
{sources.filter((s) => s.enabled).length}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="text-sm text-gray-500">Ausgewaehlt</div>
|
||||
<div className="text-3xl font-bold text-blue-600">
|
||||
{selectedTemplates.length}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<div className="flex gap-2 border-b border-gray-200">
|
||||
{(['search', 'compose', 'preview'] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
disabled={tab !== 'search' && selectedTemplates.length === 0}
|
||||
className={`px-4 py-2 font-medium transition-colors ${
|
||||
activeTab === tab
|
||||
? 'text-purple-600 border-b-2 border-purple-600'
|
||||
: 'text-gray-500 hover:text-gray-700 disabled:text-gray-300 disabled:cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
{tab === 'search' && 'Vorlagen suchen'}
|
||||
{tab === 'compose' && 'Zusammenstellen'}
|
||||
{tab === 'preview' && 'Vorschau'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Search Tab */}
|
||||
{activeTab === 'search' && (
|
||||
<div className="space-y-4">
|
||||
{/* Search Form */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex gap-4 items-end flex-wrap">
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Suche
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
placeholder="z.B. Datenschutzerklaerung, Cookie-Banner, Widerruf..."
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Dokumenttyp
|
||||
</label>
|
||||
<select
|
||||
value={selectedType}
|
||||
onChange={(e) => setSelectedType(e.target.value as TemplateType | '')}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
>
|
||||
<option value="">Alle Typen</option>
|
||||
{Object.entries(TEMPLATE_TYPE_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">
|
||||
Sprache
|
||||
</label>
|
||||
<select
|
||||
value={selectedLanguage}
|
||||
onChange={(e) => setSelectedLanguage(e.target.value as 'de' | 'en' | '')}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
>
|
||||
<option value="">Alle</option>
|
||||
<option value="de">Deutsch</option>
|
||||
<option value="en">English</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
disabled={isSearching || !searchQuery.trim()}
|
||||
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isSearching ? 'Suche...' : 'Suchen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search Results */}
|
||||
{searchResults.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-semibold text-gray-900">
|
||||
{searchResults.length} Ergebnisse
|
||||
</h3>
|
||||
{selectedTemplates.length > 0 && (
|
||||
<button
|
||||
onClick={() => setSelectedTemplates([])}
|
||||
className="text-sm text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
Auswahl aufheben
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{searchResults.map((result) => (
|
||||
<TemplateCard
|
||||
key={result.id}
|
||||
template={result}
|
||||
selected={selectedTemplates.includes(result.id)}
|
||||
onSelect={() => toggleTemplate(result.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{searchResults.length === 0 && searchQuery && !isSearching && (
|
||||
<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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Keine Vorlagen gefunden</h3>
|
||||
<p className="mt-2 text-gray-500">
|
||||
Versuchen Sie einen anderen Suchbegriff oder aendern Sie die Filter.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick Start Templates */}
|
||||
{searchResults.length === 0 && !searchQuery && (
|
||||
<div className="bg-gradient-to-r from-purple-50 to-blue-50 rounded-xl border border-purple-200 p-6">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Schnellstart - Haeufig benoetigte Dokumente</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{[
|
||||
{ query: 'Datenschutzerklaerung DSGVO', type: 'privacy_policy', icon: '🔒' },
|
||||
{ query: 'Cookie Banner', type: 'cookie_banner', icon: '🍪' },
|
||||
{ query: 'Impressum', type: 'impressum', icon: '📋' },
|
||||
{ query: 'AGB Nutzungsbedingungen', type: 'terms_of_service', icon: '📜' },
|
||||
].map((item) => (
|
||||
<button
|
||||
key={item.type}
|
||||
onClick={() => {
|
||||
setSearchQuery(item.query)
|
||||
setSelectedType(item.type as TemplateType)
|
||||
setTimeout(handleSearch, 100)
|
||||
}}
|
||||
className="p-4 bg-white rounded-lg border border-gray-200 hover:border-purple-300 hover:shadow transition-all text-center"
|
||||
>
|
||||
<span className="text-3xl mb-2 block">{item.icon}</span>
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
{TEMPLATE_TYPE_LABELS[item.type as TemplateType]}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Compose Tab */}
|
||||
{activeTab === 'compose' && selectedTemplates.length > 0 && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Main Content - 2/3 */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Selected Templates */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">
|
||||
Ausgewaehlte Bausteine ({selectedTemplates.length})
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{selectedTemplateObjects.map((t, index) => (
|
||||
<div
|
||||
key={t.id}
|
||||
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-gray-400 font-mono">{index + 1}.</span>
|
||||
<span className="font-medium">{t.documentTitle}</span>
|
||||
<LicenseBadge licenseId={t.licenseId as LicenseType} small />
|
||||
</div>
|
||||
<button
|
||||
onClick={() => toggleTemplate(t.id)}
|
||||
className="text-red-500 hover:text-red-700"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* Placeholder Editor */}
|
||||
<PlaceholderEditor
|
||||
placeholders={allPlaceholders}
|
||||
values={placeholderValues}
|
||||
onChange={(key, value) =>
|
||||
setPlaceholderValues((prev) => ({ ...prev, [key]: value }))
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Attribution Footer */}
|
||||
<AttributionFooter templates={selectedTemplateObjects} />
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-4">
|
||||
<button
|
||||
onClick={() => setActiveTab('search')}
|
||||
className="px-4 py-2 text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
Zurueck zur Suche
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('preview')}
|
||||
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
Vorschau anzeigen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar - 1/3: Einwilligungen DataPoints */}
|
||||
<div className="lg:col-span-1">
|
||||
<DataPointsPreview
|
||||
dataPoints={selectedDataPointsData || []}
|
||||
onInsertPlaceholder={handleInsertPlaceholder}
|
||||
language="de"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Preview Tab */}
|
||||
{activeTab === 'preview' && selectedTemplates.length > 0 && (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-semibold text-gray-900">Dokument-Vorschau</h3>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setActiveTab('compose')}
|
||||
className="px-4 py-2 text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
Bearbeiten
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
// Copy to clipboard
|
||||
let content = combinedContent
|
||||
for (const [key, value] of Object.entries(placeholderValues)) {
|
||||
if (value) {
|
||||
content = content.replace(new RegExp(key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), value)
|
||||
}
|
||||
}
|
||||
navigator.clipboard.writeText(content)
|
||||
}}
|
||||
className="px-4 py-2 text-purple-700 bg-purple-100 rounded-lg hover:bg-purple-200 transition-colors"
|
||||
>
|
||||
Kopieren
|
||||
</button>
|
||||
<button
|
||||
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
Als PDF exportieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Document Validation based on selected Einwilligungen */}
|
||||
{selectedDataPointsData && selectedDataPointsData.length > 0 && (
|
||||
<DocumentValidation
|
||||
dataPoints={selectedDataPointsData}
|
||||
documentContent={combinedContent}
|
||||
language="de"
|
||||
onInsertPlaceholder={handleInsertPlaceholder}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DocumentPreview
|
||||
content={combinedContent}
|
||||
placeholders={placeholderValues}
|
||||
/>
|
||||
|
||||
{/* Attribution */}
|
||||
<AttributionFooter templates={selectedTemplateObjects} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sources Info */}
|
||||
{activeTab === 'search' && sources.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Verfuegbare Quellen</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{sources.filter((s) => s.enabled).slice(0, 6).map((source) => (
|
||||
<div key={source.name} className="p-4 bg-gray-50 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="font-medium text-gray-900">{source.name}</span>
|
||||
<LicenseBadge licenseId={source.license_type as LicenseType} small />
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 line-clamp-2">{source.description}</p>
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{source.template_types.slice(0, 3).map((t: string) => (
|
||||
<span key={t} className="text-xs bg-purple-100 text-purple-700 px-1.5 py-0.5 rounded">
|
||||
{TEMPLATE_TYPE_LABELS[t as TemplateType] || t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,425 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useCallback } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useSDK } from '@/lib/sdk'
|
||||
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
||||
import { DocumentUploadSection, type UploadedDocument } from '@/components/sdk'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
interface DSFA {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
status: 'draft' | 'in-review' | 'approved' | 'needs-update'
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
approvedBy: string | null
|
||||
riskLevel: 'low' | 'medium' | 'high' | 'critical'
|
||||
processingActivity: string
|
||||
dataCategories: string[]
|
||||
recipients: string[]
|
||||
measures: string[]
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MOCK DATA
|
||||
// =============================================================================
|
||||
|
||||
const mockDSFAs: DSFA[] = [
|
||||
{
|
||||
id: 'dsfa-1',
|
||||
title: 'DSFA - Bewerber-Management-System',
|
||||
description: 'Datenschutz-Folgenabschaetzung fuer das KI-gestuetzte Bewerber-Screening',
|
||||
status: 'in-review',
|
||||
createdAt: new Date('2024-01-10'),
|
||||
updatedAt: new Date('2024-01-20'),
|
||||
approvedBy: null,
|
||||
riskLevel: 'high',
|
||||
processingActivity: 'Automatisierte Bewertung von Bewerbungsunterlagen',
|
||||
dataCategories: ['Kontaktdaten', 'Beruflicher Werdegang', 'Qualifikationen'],
|
||||
recipients: ['HR-Abteilung', 'Fachabteilungen'],
|
||||
measures: ['Verschluesselung', 'Zugriffskontrolle', 'Menschliche Pruefung'],
|
||||
},
|
||||
{
|
||||
id: 'dsfa-2',
|
||||
title: 'DSFA - Video-Ueberwachung Buero',
|
||||
description: 'Datenschutz-Folgenabschaetzung fuer die Videoueberwachung im Buerogebaeude',
|
||||
status: 'approved',
|
||||
createdAt: new Date('2023-11-01'),
|
||||
updatedAt: new Date('2023-12-15'),
|
||||
approvedBy: 'DSB Mueller',
|
||||
riskLevel: 'medium',
|
||||
processingActivity: 'Videoueberwachung zu Sicherheitszwecken',
|
||||
dataCategories: ['Bilddaten', 'Bewegungsdaten'],
|
||||
recipients: ['Sicherheitsdienst'],
|
||||
measures: ['Loeschfristen', 'Zugriffsbeschraenkung', 'Hinweisschilder'],
|
||||
},
|
||||
{
|
||||
id: 'dsfa-3',
|
||||
title: 'DSFA - Kundenanalyse',
|
||||
description: 'Datenschutz-Folgenabschaetzung fuer Big-Data-Kundenanalysen',
|
||||
status: 'draft',
|
||||
createdAt: new Date('2024-01-22'),
|
||||
updatedAt: new Date('2024-01-22'),
|
||||
approvedBy: null,
|
||||
riskLevel: 'high',
|
||||
processingActivity: 'Analyse von Kundenverhalten fuer Marketing',
|
||||
dataCategories: ['Kaufhistorie', 'Nutzungsverhalten', 'Praeferenzen'],
|
||||
recipients: ['Marketing', 'Vertrieb'],
|
||||
measures: [],
|
||||
},
|
||||
]
|
||||
|
||||
// =============================================================================
|
||||
// COMPONENTS
|
||||
// =============================================================================
|
||||
|
||||
function DSFACard({ dsfa }: { dsfa: DSFA }) {
|
||||
const statusColors = {
|
||||
draft: 'bg-gray-100 text-gray-600 border-gray-200',
|
||||
'in-review': 'bg-yellow-100 text-yellow-700 border-yellow-200',
|
||||
approved: 'bg-green-100 text-green-700 border-green-200',
|
||||
'needs-update': 'bg-orange-100 text-orange-700 border-orange-200',
|
||||
}
|
||||
|
||||
const statusLabels = {
|
||||
draft: 'Entwurf',
|
||||
'in-review': 'In Pruefung',
|
||||
approved: 'Genehmigt',
|
||||
'needs-update': 'Aktualisierung erforderlich',
|
||||
}
|
||||
|
||||
const riskColors = {
|
||||
low: 'bg-green-100 text-green-700',
|
||||
medium: 'bg-yellow-100 text-yellow-700',
|
||||
high: 'bg-orange-100 text-orange-700',
|
||||
critical: 'bg-red-100 text-red-700',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-xl border-2 p-6 ${
|
||||
dsfa.status === 'needs-update' ? 'border-orange-200' :
|
||||
dsfa.status === 'approved' ? 'border-green-200' : 'border-gray-200'
|
||||
}`}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${statusColors[dsfa.status]}`}>
|
||||
{statusLabels[dsfa.status]}
|
||||
</span>
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${riskColors[dsfa.riskLevel]}`}>
|
||||
Risiko: {dsfa.riskLevel === 'low' ? 'Niedrig' :
|
||||
dsfa.riskLevel === 'medium' ? 'Mittel' :
|
||||
dsfa.riskLevel === 'high' ? 'Hoch' : 'Kritisch'}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">{dsfa.title}</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">{dsfa.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 text-sm text-gray-600">
|
||||
<p><span className="text-gray-500">Verarbeitungstaetigkeit:</span> {dsfa.processingActivity}</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-1">
|
||||
{dsfa.dataCategories.map(cat => (
|
||||
<span key={cat} className="px-2 py-0.5 text-xs bg-blue-50 text-blue-600 rounded">
|
||||
{cat}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{dsfa.measures.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<span className="text-sm text-gray-500">Massnahmen:</span>
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{dsfa.measures.map(m => (
|
||||
<span key={m} className="px-2 py-0.5 text-xs bg-green-50 text-green-600 rounded">
|
||||
{m}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between text-sm">
|
||||
<div className="text-gray-500">
|
||||
<span>Erstellt: {dsfa.createdAt.toLocaleDateString('de-DE')}</span>
|
||||
{dsfa.approvedBy && (
|
||||
<span className="ml-4">Genehmigt von: {dsfa.approvedBy}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="px-3 py-1 text-purple-600 hover:bg-purple-50 rounded-lg transition-colors">
|
||||
Bearbeiten
|
||||
</button>
|
||||
<button className="px-3 py-1 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
Exportieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function GeneratorWizard({ onClose }: { onClose: () => void }) {
|
||||
const [step, setStep] = useState(1)
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Neue DSFA erstellen</h3>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
||||
<svg className="w-6 h-6" 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>
|
||||
|
||||
{/* Progress Steps */}
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
{[1, 2, 3, 4].map(s => (
|
||||
<React.Fragment key={s}>
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
|
||||
s < step ? 'bg-green-500 text-white' :
|
||||
s === step ? 'bg-purple-600 text-white' : 'bg-gray-200 text-gray-500'
|
||||
}`}>
|
||||
{s < step ? (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : s}
|
||||
</div>
|
||||
{s < 4 && <div className={`flex-1 h-1 ${s < step ? 'bg-green-500' : 'bg-gray-200'}`} />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Step Content */}
|
||||
<div className="min-h-48">
|
||||
{step === 1 && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Titel der DSFA</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="z.B. DSFA - Mitarbeiter-Monitoring"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung der Verarbeitung</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
placeholder="Beschreiben Sie die geplante Datenverarbeitung..."
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{step === 2 && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Datenkategorien</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{['Kontaktdaten', 'Identifikationsdaten', 'Finanzdaten', 'Gesundheitsdaten', 'Standortdaten', 'Nutzungsdaten'].map(cat => (
|
||||
<label key={cat} className="flex items-center gap-2 p-2 border rounded-lg hover:bg-gray-50">
|
||||
<input type="checkbox" className="w-4 h-4 text-purple-600" />
|
||||
<span className="text-sm">{cat}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{step === 3 && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Risikobewertung</label>
|
||||
<p className="text-sm text-gray-500 mb-4">Bewerten Sie die Risiken fuer die Rechte und Freiheiten der Betroffenen.</p>
|
||||
<div className="space-y-2">
|
||||
{['Niedrig', 'Mittel', 'Hoch', 'Kritisch'].map(level => (
|
||||
<label key={level} className="flex items-center gap-3 p-3 border rounded-lg hover:bg-gray-50 cursor-pointer">
|
||||
<input type="radio" name="risk" className="w-4 h-4 text-purple-600" />
|
||||
<span className="text-sm font-medium">{level}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{step === 4 && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Schutzmassnahmen</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{['Verschluesselung', 'Pseudonymisierung', 'Zugriffskontrolle', 'Loeschkonzept', 'Schulungen', 'Menschliche Pruefung'].map(m => (
|
||||
<label key={m} className="flex items-center gap-2 p-2 border rounded-lg hover:bg-gray-50">
|
||||
<input type="checkbox" className="w-4 h-4 text-purple-600" />
|
||||
<span className="text-sm">{m}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex items-center justify-between mt-6 pt-4 border-t border-gray-200">
|
||||
<button
|
||||
onClick={() => step > 1 ? setStep(step - 1) : onClose()}
|
||||
className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
{step === 1 ? 'Abbrechen' : 'Zurueck'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => step < 4 ? setStep(step + 1) : onClose()}
|
||||
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
{step === 4 ? 'DSFA erstellen' : 'Weiter'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN PAGE
|
||||
// =============================================================================
|
||||
|
||||
export default function DSFAPage() {
|
||||
const router = useRouter()
|
||||
const { state } = useSDK()
|
||||
const [dsfas] = useState<DSFA[]>(mockDSFAs)
|
||||
const [showGenerator, setShowGenerator] = useState(false)
|
||||
const [filter, setFilter] = useState<string>('all')
|
||||
|
||||
// Handle uploaded document
|
||||
const handleDocumentProcessed = useCallback((doc: UploadedDocument) => {
|
||||
console.log('[DSFA Page] Document processed:', doc)
|
||||
}, [])
|
||||
|
||||
// Open document in workflow editor
|
||||
const handleOpenInEditor = useCallback((doc: UploadedDocument) => {
|
||||
router.push(`/sdk/workflow?documentType=dsfa&documentId=${doc.id}&mode=change`)
|
||||
}, [router])
|
||||
|
||||
const filteredDSFAs = filter === 'all'
|
||||
? dsfas
|
||||
: dsfas.filter(d => d.status === filter)
|
||||
|
||||
const draftCount = dsfas.filter(d => d.status === 'draft').length
|
||||
const inReviewCount = dsfas.filter(d => d.status === 'in-review').length
|
||||
const approvedCount = dsfas.filter(d => d.status === 'approved').length
|
||||
|
||||
const stepInfo = STEP_EXPLANATIONS['dsfa']
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Step Header */}
|
||||
<StepHeader
|
||||
stepId="dsfa"
|
||||
title={stepInfo.title}
|
||||
description={stepInfo.description}
|
||||
explanation={stepInfo.explanation}
|
||||
tips={stepInfo.tips}
|
||||
>
|
||||
{!showGenerator && (
|
||||
<button
|
||||
onClick={() => setShowGenerator(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 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
Neue DSFA
|
||||
</button>
|
||||
)}
|
||||
</StepHeader>
|
||||
|
||||
{/* Generator */}
|
||||
{showGenerator && (
|
||||
<GeneratorWizard onClose={() => setShowGenerator(false)} />
|
||||
)}
|
||||
|
||||
{/* Document Upload Section */}
|
||||
<DocumentUploadSection
|
||||
documentType="dsfa"
|
||||
onDocumentProcessed={handleDocumentProcessed}
|
||||
onOpenInEditor={handleOpenInEditor}
|
||||
/>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="text-sm text-gray-500">Gesamt</div>
|
||||
<div className="text-3xl font-bold text-gray-900">{dsfas.length}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="text-sm text-gray-500">Entwuerfe</div>
|
||||
<div className="text-3xl font-bold text-gray-500">{draftCount}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-yellow-200 p-6">
|
||||
<div className="text-sm text-yellow-600">In Pruefung</div>
|
||||
<div className="text-3xl font-bold text-yellow-600">{inReviewCount}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-green-200 p-6">
|
||||
<div className="text-sm text-green-600">Genehmigt</div>
|
||||
<div className="text-3xl font-bold text-green-600">{approvedCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-500">Filter:</span>
|
||||
{['all', 'draft', 'in-review', 'approved', 'needs-update'].map(f => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
className={`px-3 py-1 text-sm rounded-full transition-colors ${
|
||||
filter === f
|
||||
? 'bg-purple-600 text-white'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{f === 'all' ? 'Alle' :
|
||||
f === 'draft' ? 'Entwuerfe' :
|
||||
f === 'in-review' ? 'In Pruefung' :
|
||||
f === 'approved' ? 'Genehmigt' : 'Update erforderlich'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* DSFA List */}
|
||||
<div className="space-y-4">
|
||||
{filteredDSFAs.map(dsfa => (
|
||||
<DSFACard key={dsfa.id} dsfa={dsfa} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredDSFAs.length === 0 && !showGenerator && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
|
||||
<div className="w-16 h-16 mx-auto bg-purple-100 rounded-full flex items-center justify-center mb-4">
|
||||
<svg className="w-8 h-8 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Keine DSFAs gefunden</h3>
|
||||
<p className="mt-2 text-gray-500">Erstellen Sie eine neue Datenschutz-Folgenabschaetzung.</p>
|
||||
<button
|
||||
onClick={() => setShowGenerator(true)}
|
||||
className="mt-4 px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
Erste DSFA erstellen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,749 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import {
|
||||
DSRRequest,
|
||||
DSR_TYPE_INFO,
|
||||
DSR_STATUS_INFO,
|
||||
getDaysRemaining,
|
||||
isOverdue,
|
||||
isUrgent,
|
||||
DSRCommunication,
|
||||
DSRVerifyIdentityRequest
|
||||
} from '@/lib/sdk/dsr/types'
|
||||
import { fetchSDKDSR, updateSDKDSRStatus } from '@/lib/sdk/dsr/api'
|
||||
import {
|
||||
DSRWorkflowStepper,
|
||||
DSRIdentityModal,
|
||||
DSRCommunicationLog,
|
||||
DSRErasureChecklistComponent,
|
||||
DSRDataExportComponent
|
||||
} from '@/components/sdk/dsr'
|
||||
|
||||
// =============================================================================
|
||||
// MOCK COMMUNICATIONS
|
||||
// =============================================================================
|
||||
|
||||
const mockCommunications: DSRCommunication[] = [
|
||||
{
|
||||
id: 'comm-001',
|
||||
dsrId: 'dsr-001',
|
||||
type: 'outgoing',
|
||||
channel: 'email',
|
||||
subject: 'Eingangsbestaetigung Ihrer Anfrage',
|
||||
content: 'Sehr geehrte(r) Antragsteller(in),\n\nwir bestaetigen den Eingang Ihrer Anfrage und werden diese innerhalb der gesetzlichen Frist bearbeiten.\n\nMit freundlichen Gruessen\nIhr Datenschutz-Team',
|
||||
sentAt: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
sentBy: 'System',
|
||||
createdAt: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
createdBy: 'System'
|
||||
},
|
||||
{
|
||||
id: 'comm-002',
|
||||
dsrId: 'dsr-001',
|
||||
type: 'outgoing',
|
||||
channel: 'email',
|
||||
subject: 'Identitaetspruefung erforderlich',
|
||||
content: 'Sehr geehrte(r) Antragsteller(in),\n\nbitte senden Sie uns zur Bearbeitung Ihrer Anfrage einen Identitaetsnachweis zu.\n\nMit freundlichen Gruessen',
|
||||
sentAt: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
sentBy: 'DSB Mueller',
|
||||
createdAt: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
createdBy: 'DSB Mueller'
|
||||
}
|
||||
]
|
||||
|
||||
// =============================================================================
|
||||
// COMPONENTS
|
||||
// =============================================================================
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const info = DSR_STATUS_INFO[status as keyof typeof DSR_STATUS_INFO]
|
||||
if (!info) return null
|
||||
|
||||
return (
|
||||
<span className={`px-3 py-1.5 text-sm font-medium rounded-lg ${info.bgColor} ${info.color} border ${info.borderColor}`}>
|
||||
{info.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function DeadlineDisplay({ request }: { request: DSRRequest }) {
|
||||
const daysRemaining = getDaysRemaining(request.deadline.currentDeadline)
|
||||
const overdue = isOverdue(request)
|
||||
const urgent = isUrgent(request)
|
||||
const isTerminal = request.status === 'completed' || request.status === 'rejected' || request.status === 'cancelled'
|
||||
|
||||
if (isTerminal) {
|
||||
return (
|
||||
<div className="text-gray-500">
|
||||
<div className="text-sm">Abgeschlossen am</div>
|
||||
<div className="text-lg font-semibold">
|
||||
{request.completedAt
|
||||
? new Date(request.completedAt).toLocaleDateString('de-DE')
|
||||
: '-'
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${overdue ? 'text-red-600' : urgent ? 'text-orange-600' : 'text-gray-900'}`}>
|
||||
<div className="text-sm">Frist</div>
|
||||
<div className="text-2xl font-bold">
|
||||
{overdue
|
||||
? `${Math.abs(daysRemaining)} Tage ueberfaellig`
|
||||
: `${daysRemaining} Tage`
|
||||
}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
bis {new Date(request.deadline.currentDeadline).toLocaleDateString('de-DE')}
|
||||
</div>
|
||||
{request.deadline.extended && (
|
||||
<div className="text-xs text-purple-600 mt-1">
|
||||
(Verlaengert)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ActionButtons({
|
||||
request,
|
||||
onVerifyIdentity,
|
||||
onExtendDeadline,
|
||||
onComplete,
|
||||
onReject,
|
||||
onAssign
|
||||
}: {
|
||||
request: DSRRequest
|
||||
onVerifyIdentity: () => void
|
||||
onExtendDeadline: () => void
|
||||
onComplete: () => void
|
||||
onReject: () => void
|
||||
onAssign: () => void
|
||||
}) {
|
||||
const isTerminal = request.status === 'completed' || request.status === 'rejected' || request.status === 'cancelled'
|
||||
|
||||
if (isTerminal) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<button className="w-full px-4 py-2 text-gray-600 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors text-sm">
|
||||
PDF exportieren
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{!request.identityVerification.verified && (
|
||||
<button
|
||||
onClick={onVerifyIdentity}
|
||||
className="w-full px-4 py-2 bg-yellow-500 text-white hover:bg-yellow-600 rounded-lg transition-colors text-sm font-medium"
|
||||
>
|
||||
Identitaet verifizieren
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={onAssign}
|
||||
className="w-full px-4 py-2 text-purple-600 bg-purple-50 hover:bg-purple-100 rounded-lg transition-colors text-sm"
|
||||
>
|
||||
{request.assignment.assignedTo ? 'Neu zuweisen' : 'Zuweisen'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onExtendDeadline}
|
||||
className="w-full px-4 py-2 text-gray-600 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors text-sm"
|
||||
>
|
||||
Frist verlaengern
|
||||
</button>
|
||||
|
||||
<div className="border-t border-gray-200 pt-2 mt-2">
|
||||
<button
|
||||
onClick={onComplete}
|
||||
className="w-full px-4 py-2 bg-green-600 text-white hover:bg-green-700 rounded-lg transition-colors text-sm font-medium"
|
||||
>
|
||||
Abschliessen
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onReject}
|
||||
className="w-full mt-2 px-4 py-2 text-red-600 bg-red-50 hover:bg-red-100 rounded-lg transition-colors text-sm"
|
||||
>
|
||||
Ablehnen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AuditLog({ request }: { request: DSRRequest }) {
|
||||
type AuditEvent = { action: string; timestamp: string; user: string }
|
||||
|
||||
const events: AuditEvent[] = [
|
||||
{ action: 'Erstellt', timestamp: request.createdAt, user: request.createdBy }
|
||||
]
|
||||
|
||||
if (request.assignment.assignedAt) {
|
||||
events.push({
|
||||
action: `Zugewiesen an ${request.assignment.assignedTo}`,
|
||||
timestamp: request.assignment.assignedAt,
|
||||
user: request.assignment.assignedBy || 'System'
|
||||
})
|
||||
}
|
||||
|
||||
if (request.identityVerification.verifiedAt) {
|
||||
events.push({
|
||||
action: 'Identitaet verifiziert',
|
||||
timestamp: request.identityVerification.verifiedAt,
|
||||
user: request.identityVerification.verifiedBy || 'System'
|
||||
})
|
||||
}
|
||||
|
||||
if (request.completedAt) {
|
||||
events.push({
|
||||
action: request.status === 'rejected' ? 'Abgelehnt' : 'Abgeschlossen',
|
||||
timestamp: request.completedAt,
|
||||
user: request.updatedBy || 'System'
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium text-gray-700">Aktivitaeten</h4>
|
||||
<div className="space-y-2">
|
||||
{events.map((event, idx) => (
|
||||
<div key={idx} className="flex items-start gap-2 text-xs">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-gray-300 mt-1.5 flex-shrink-0" />
|
||||
<div>
|
||||
<div className="text-gray-900">{event.action}</div>
|
||||
<div className="text-gray-500">
|
||||
{new Date(event.timestamp).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}
|
||||
{' - '}
|
||||
{event.user}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN PAGE
|
||||
// =============================================================================
|
||||
|
||||
export default function DSRDetailPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const requestId = params.requestId as string
|
||||
|
||||
const [request, setRequest] = useState<DSRRequest | null>(null)
|
||||
const [communications, setCommunications] = useState<DSRCommunication[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [showIdentityModal, setShowIdentityModal] = useState(false)
|
||||
const [activeContentTab, setActiveContentTab] = useState<'details' | 'communication' | 'type-specific'>('details')
|
||||
|
||||
// Load data from SDK backend
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const found = await fetchSDKDSR(requestId)
|
||||
if (found) {
|
||||
setRequest(found)
|
||||
// Communications are loaded as mock for now (no backend API yet)
|
||||
setCommunications(mockCommunications.filter(c => c.dsrId === requestId))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load DSR:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
loadData()
|
||||
}, [requestId])
|
||||
|
||||
const handleVerifyIdentity = async (verification: DSRVerifyIdentityRequest) => {
|
||||
if (!request) return
|
||||
try {
|
||||
await updateSDKDSRStatus(request.id, 'verified')
|
||||
setRequest({
|
||||
...request,
|
||||
identityVerification: {
|
||||
verified: true,
|
||||
method: verification.method,
|
||||
verifiedAt: new Date().toISOString(),
|
||||
verifiedBy: 'Current User',
|
||||
notes: verification.notes
|
||||
},
|
||||
status: request.status === 'identity_verification' ? 'processing' : request.status
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Failed to verify identity:', err)
|
||||
// Still update locally as fallback
|
||||
setRequest({
|
||||
...request,
|
||||
identityVerification: {
|
||||
verified: true,
|
||||
method: verification.method,
|
||||
verifiedAt: new Date().toISOString(),
|
||||
verifiedBy: 'Current User',
|
||||
notes: verification.notes
|
||||
},
|
||||
status: request.status === 'identity_verification' ? 'processing' : request.status
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleSendCommunication = async (message: any) => {
|
||||
const newComm: DSRCommunication = {
|
||||
id: `comm-${Date.now()}`,
|
||||
dsrId: requestId,
|
||||
...message,
|
||||
createdAt: new Date().toISOString(),
|
||||
createdBy: 'Current User',
|
||||
sentAt: message.type === 'outgoing' ? new Date().toISOString() : undefined,
|
||||
sentBy: message.type === 'outgoing' ? 'Current User' : undefined
|
||||
}
|
||||
setCommunications(prev => [newComm, ...prev])
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
if (!request) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
|
||||
<div className="w-16 h-16 mx-auto bg-red-100 rounded-full 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="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Anfrage nicht gefunden</h3>
|
||||
<p className="mt-2 text-gray-500">
|
||||
Die angeforderte DSR-Anfrage existiert nicht oder wurde geloescht.
|
||||
</p>
|
||||
<Link
|
||||
href="/sdk/dsr"
|
||||
className="mt-4 inline-flex items-center gap-2 px-4 py-2 text-purple-600 hover:bg-purple-50 rounded-lg 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="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
Zurueck zur Uebersicht
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const typeInfo = DSR_TYPE_INFO[request.type]
|
||||
const overdue = isOverdue(request)
|
||||
const urgent = isUrgent(request)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
href="/sdk/dsr"
|
||||
className="p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg 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="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
</Link>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-500 font-mono">{request.referenceNumber}</span>
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${typeInfo.bgColor} ${typeInfo.color}`}>
|
||||
{typeInfo.article} {typeInfo.label}
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mt-1">
|
||||
{request.requester.name}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
<button className="flex items-center gap-2 px-4 py-2 text-gray-600 bg-gray-100 hover:bg-gray-200 rounded-lg 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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
Exportieren
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Workflow Stepper */}
|
||||
<div className={`
|
||||
bg-white rounded-xl border-2 p-6
|
||||
${overdue ? 'border-red-200' : urgent ? 'border-orange-200' : 'border-gray-200'}
|
||||
`}>
|
||||
<DSRWorkflowStepper currentStatus={request.status} />
|
||||
</div>
|
||||
|
||||
{/* Main Content: 2/3 + 1/3 Layout */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Left Column - 2/3 */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Content Tabs */}
|
||||
<div className="bg-white rounded-xl border border-gray-200">
|
||||
<div className="border-b border-gray-200">
|
||||
<nav className="flex -mb-px">
|
||||
{[
|
||||
{ id: 'details', label: 'Details' },
|
||||
{ id: 'communication', label: 'Kommunikation' },
|
||||
{ id: 'type-specific', label: typeInfo.labelShort }
|
||||
].map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveContentTab(tab.id as any)}
|
||||
className={`
|
||||
px-6 py-4 text-sm font-medium border-b-2 transition-colors
|
||||
${activeContentTab === tab.id
|
||||
? 'border-purple-600 text-purple-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
{/* Details Tab */}
|
||||
{activeContentTab === 'details' && (
|
||||
<div className="space-y-6">
|
||||
{/* Request Info */}
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-500 mb-2">Antragsteller</h4>
|
||||
<div className="space-y-2">
|
||||
<div className="font-medium text-gray-900">{request.requester.name}</div>
|
||||
<div className="text-sm text-gray-600">{request.requester.email}</div>
|
||||
{request.requester.phone && (
|
||||
<div className="text-sm text-gray-600">{request.requester.phone}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-500 mb-2">Eingereicht</h4>
|
||||
<div className="space-y-2">
|
||||
<div className="font-medium text-gray-900">
|
||||
{new Date(request.receivedAt).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
})}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
Quelle: {request.source === 'web_form' ? 'Kontaktformular' :
|
||||
request.source === 'email' ? 'E-Mail' :
|
||||
request.source === 'letter' ? 'Brief' :
|
||||
request.source === 'phone' ? 'Telefon' : request.source}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Identity Verification */}
|
||||
<div className={`
|
||||
p-4 rounded-xl border
|
||||
${request.identityVerification.verified
|
||||
? 'bg-green-50 border-green-200'
|
||||
: 'bg-yellow-50 border-yellow-200'
|
||||
}
|
||||
`}>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`
|
||||
w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0
|
||||
${request.identityVerification.verified ? 'bg-green-100' : 'bg-yellow-100'}
|
||||
`}>
|
||||
{request.identityVerification.verified ? (
|
||||
<svg className="w-4 h-4 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4 text-yellow-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">
|
||||
<div className={`font-medium ${request.identityVerification.verified ? 'text-green-800' : 'text-yellow-800'}`}>
|
||||
{request.identityVerification.verified
|
||||
? 'Identitaet verifiziert'
|
||||
: 'Identitaetspruefung ausstehend'
|
||||
}
|
||||
</div>
|
||||
{request.identityVerification.verified && (
|
||||
<div className="text-sm text-green-700 mt-1">
|
||||
Methode: {request.identityVerification.method === 'id_document' ? 'Ausweisdokument' :
|
||||
request.identityVerification.method === 'email' ? 'E-Mail' :
|
||||
request.identityVerification.method === 'existing_account' ? 'Bestehendes Konto' :
|
||||
request.identityVerification.method}
|
||||
{' | '}
|
||||
{new Date(request.identityVerification.verifiedAt!).toLocaleDateString('de-DE')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!request.identityVerification.verified && (
|
||||
<button
|
||||
onClick={() => setShowIdentityModal(true)}
|
||||
className="px-3 py-1.5 bg-yellow-600 text-white rounded-lg hover:bg-yellow-700 transition-colors text-sm"
|
||||
>
|
||||
Jetzt pruefen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Request Text */}
|
||||
{request.requestText && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-500 mb-2">Anfragetext</h4>
|
||||
<div className="bg-gray-50 rounded-xl p-4 text-gray-700 whitespace-pre-wrap">
|
||||
{request.requestText}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
{request.notes && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-500 mb-2">Notizen</h4>
|
||||
<div className="bg-gray-50 rounded-xl p-4 text-gray-700">
|
||||
{request.notes}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Communication Tab */}
|
||||
{activeContentTab === 'communication' && (
|
||||
<DSRCommunicationLog
|
||||
communications={communications}
|
||||
onSendMessage={handleSendCommunication}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Type-Specific Tab */}
|
||||
{activeContentTab === 'type-specific' && (
|
||||
<div>
|
||||
{/* Art. 17 - Erasure */}
|
||||
{request.type === 'erasure' && (
|
||||
<DSRErasureChecklistComponent
|
||||
checklist={request.erasureChecklist}
|
||||
onChange={(checklist) => setRequest({ ...request, erasureChecklist: checklist })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Art. 15/20 - Data Export */}
|
||||
{(request.type === 'access' || request.type === 'portability') && (
|
||||
<DSRDataExportComponent
|
||||
dsrId={request.id}
|
||||
dsrType={request.type}
|
||||
existingExport={request.dataExport}
|
||||
onGenerate={async (format) => {
|
||||
// Mock generation
|
||||
setRequest({
|
||||
...request,
|
||||
dataExport: {
|
||||
format,
|
||||
generatedAt: new Date().toISOString(),
|
||||
generatedBy: 'Current User',
|
||||
fileName: `datenexport_${request.referenceNumber}.${format}`,
|
||||
fileSize: 125000,
|
||||
includesThirdPartyData: true
|
||||
}
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Art. 16 - Rectification */}
|
||||
{request.type === 'rectification' && request.rectificationDetails && (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Zu korrigierende Daten</h3>
|
||||
<div className="space-y-3">
|
||||
{request.rectificationDetails.fieldsToCorrect.map((field, idx) => (
|
||||
<div key={idx} className="bg-gray-50 rounded-xl p-4">
|
||||
<div className="font-medium text-gray-900 mb-2">{field.field}</div>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<div className="text-gray-500 mb-1">Aktueller Wert</div>
|
||||
<div className="text-red-600 line-through">{field.currentValue}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500 mb-1">Angeforderter Wert</div>
|
||||
<div className="text-green-600">{field.requestedValue}</div>
|
||||
</div>
|
||||
</div>
|
||||
{field.corrected && (
|
||||
<div className="mt-2 text-xs text-green-600">
|
||||
Korrigiert am {new Date(field.correctedAt!).toLocaleDateString('de-DE')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Art. 21 - Objection */}
|
||||
{request.type === 'objection' && request.objectionDetails && (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Widerspruchsdetails</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="bg-gray-50 rounded-xl p-4">
|
||||
<div className="text-sm text-gray-500 mb-1">Verarbeitungszweck</div>
|
||||
<div className="font-medium">{request.objectionDetails.processingPurpose}</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-xl p-4">
|
||||
<div className="text-sm text-gray-500 mb-1">Rechtsgrundlage</div>
|
||||
<div className="font-medium">{request.objectionDetails.legalBasis}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-xl p-4">
|
||||
<div className="text-sm text-gray-500 mb-1">Widerspruchsgruende</div>
|
||||
<div>{request.objectionDetails.objectionGrounds}</div>
|
||||
</div>
|
||||
{request.objectionDetails.decision !== 'pending' && (
|
||||
<div className={`
|
||||
rounded-xl p-4 border
|
||||
${request.objectionDetails.decision === 'accepted'
|
||||
? 'bg-green-50 border-green-200'
|
||||
: 'bg-red-50 border-red-200'
|
||||
}
|
||||
`}>
|
||||
<div className={`font-medium ${
|
||||
request.objectionDetails.decision === 'accepted' ? 'text-green-800' : 'text-red-800'
|
||||
}`}>
|
||||
Widerspruch {request.objectionDetails.decision === 'accepted' ? 'angenommen' : 'abgelehnt'}
|
||||
</div>
|
||||
{request.objectionDetails.decisionReason && (
|
||||
<div className={`text-sm mt-1 ${
|
||||
request.objectionDetails.decision === 'accepted' ? 'text-green-700' : 'text-red-700'
|
||||
}`}>
|
||||
{request.objectionDetails.decisionReason}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Default for restriction */}
|
||||
{request.type === 'restriction' && (
|
||||
<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" 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>
|
||||
<div className="font-medium text-blue-800">Einschraenkung der Verarbeitung</div>
|
||||
<p className="text-sm text-blue-700 mt-1">
|
||||
Markieren Sie die betroffenen Daten im System als eingeschraenkt.
|
||||
Die Daten duerfen nur noch gespeichert, aber nicht mehr verarbeitet werden.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column - 1/3 Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* Status Card */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-medium text-gray-900">Status</h3>
|
||||
<StatusBadge status={request.status} />
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-100 pt-4">
|
||||
<DeadlineDisplay request={request} />
|
||||
</div>
|
||||
|
||||
{/* Priority */}
|
||||
<div className="border-t border-gray-100 pt-4">
|
||||
<div className="text-sm text-gray-500 mb-1">Prioritaet</div>
|
||||
<div className={`
|
||||
inline-flex px-2 py-1 text-sm font-medium rounded-lg
|
||||
${request.priority === 'critical' ? 'bg-red-100 text-red-700' :
|
||||
request.priority === 'high' ? 'bg-orange-100 text-orange-700' :
|
||||
request.priority === 'normal' ? 'bg-gray-100 text-gray-700' :
|
||||
'bg-blue-100 text-blue-700'
|
||||
}
|
||||
`}>
|
||||
{request.priority === 'critical' ? 'Kritisch' :
|
||||
request.priority === 'high' ? 'Hoch' :
|
||||
request.priority === 'normal' ? 'Normal' : 'Niedrig'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Assignment */}
|
||||
<div className="border-t border-gray-100 pt-4">
|
||||
<div className="text-sm text-gray-500 mb-1">Zugewiesen an</div>
|
||||
<div className="font-medium text-gray-900">
|
||||
{request.assignment.assignedTo || 'Nicht zugewiesen'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions Card */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="font-medium text-gray-900 mb-4">Aktionen</h3>
|
||||
<ActionButtons
|
||||
request={request}
|
||||
onVerifyIdentity={() => setShowIdentityModal(true)}
|
||||
onExtendDeadline={() => alert('Fristverlaengerung - Coming soon')}
|
||||
onComplete={() => alert('Abschliessen - Coming soon')}
|
||||
onReject={() => alert('Ablehnen - Coming soon')}
|
||||
onAssign={() => alert('Zuweisen - Coming soon')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Audit Log Card */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<AuditLog request={request} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Identity Modal */}
|
||||
<DSRIdentityModal
|
||||
isOpen={showIdentityModal}
|
||||
onClose={() => setShowIdentityModal(false)}
|
||||
onVerify={handleVerifyIdentity}
|
||||
requesterName={request.requester.name}
|
||||
requesterEmail={request.requester.email}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,592 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect, useMemo } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useSDK } from '@/lib/sdk'
|
||||
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
||||
import {
|
||||
DSRRequest,
|
||||
DSRType,
|
||||
DSRStatus,
|
||||
DSRStatistics,
|
||||
DSR_TYPE_INFO,
|
||||
DSR_STATUS_INFO,
|
||||
getDaysRemaining,
|
||||
isOverdue,
|
||||
isUrgent
|
||||
} from '@/lib/sdk/dsr/types'
|
||||
import { fetchSDKDSRList } from '@/lib/sdk/dsr/api'
|
||||
import { DSRWorkflowStepperCompact } from '@/components/sdk/dsr'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
type TabId = 'overview' | 'intake' | 'processing' | 'completed' | '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'
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
function RequestCard({ request }: { request: DSRRequest }) {
|
||||
const typeInfo = DSR_TYPE_INFO[request.type]
|
||||
const statusInfo = DSR_STATUS_INFO[request.status]
|
||||
const daysRemaining = getDaysRemaining(request.deadline.currentDeadline)
|
||||
const overdue = isOverdue(request)
|
||||
const urgent = isUrgent(request)
|
||||
|
||||
return (
|
||||
<Link href={`/sdk/dsr/${request.id}`}>
|
||||
<div className={`
|
||||
bg-white rounded-xl border-2 p-6 hover:shadow-md transition-all cursor-pointer
|
||||
${overdue ? 'border-red-300 hover:border-red-400' :
|
||||
urgent ? 'border-orange-300 hover:border-orange-400' :
|
||||
request.status === 'completed' ? 'border-green-200 hover:border-green-300' :
|
||||
'border-gray-200 hover:border-purple-300'
|
||||
}
|
||||
`}>
|
||||
<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">
|
||||
{request.referenceNumber}
|
||||
</span>
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${typeInfo.bgColor} ${typeInfo.color}`}>
|
||||
{typeInfo.article} {typeInfo.labelShort}
|
||||
</span>
|
||||
{!request.identityVerification.verified && request.status !== 'completed' && request.status !== 'rejected' && (
|
||||
<span className="px-2 py-1 text-xs bg-yellow-100 text-yellow-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>
|
||||
ID fehlt
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Requester Info */}
|
||||
<h3 className="text-lg font-semibold text-gray-900 truncate">
|
||||
{request.requester.name}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 truncate">{request.requester.email}</p>
|
||||
|
||||
{/* Workflow Status */}
|
||||
<div className="mt-3">
|
||||
<DSRWorkflowStepperCompact currentStatus={request.status} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Side - Deadline */}
|
||||
<div className={`text-right ml-4 ${
|
||||
overdue ? 'text-red-600' :
|
||||
urgent ? 'text-orange-600' :
|
||||
'text-gray-500'
|
||||
}`}>
|
||||
<div className="text-sm font-medium">
|
||||
{request.status === 'completed' || request.status === 'rejected' || request.status === 'cancelled'
|
||||
? statusInfo.label
|
||||
: overdue
|
||||
? `${Math.abs(daysRemaining)} Tage ueberfaellig`
|
||||
: `${daysRemaining} Tage`
|
||||
}
|
||||
</div>
|
||||
<div className="text-xs mt-0.5">
|
||||
{new Date(request.receivedAt).toLocaleDateString('de-DE')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes Preview */}
|
||||
{request.notes && (
|
||||
<div className="mt-3 p-3 bg-gray-50 rounded-lg text-sm text-gray-600 line-clamp-2">
|
||||
{request.notes}
|
||||
</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">
|
||||
{request.assignment.assignedTo
|
||||
? `Zugewiesen: ${request.assignment.assignedTo}`
|
||||
: 'Nicht zugewiesen'
|
||||
}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{request.status !== 'completed' && request.status !== 'rejected' && request.status !== 'cancelled' && (
|
||||
<>
|
||||
{!request.identityVerification.verified && (
|
||||
<span className="px-3 py-1 text-sm bg-yellow-50 text-yellow-700 rounded-lg">
|
||||
ID pruefen
|
||||
</span>
|
||||
)}
|
||||
<span className="px-3 py-1 text-sm text-purple-600 hover:bg-purple-50 rounded-lg transition-colors">
|
||||
Bearbeiten
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{request.status === 'completed' && (
|
||||
<span className="px-3 py-1 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
Details
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
function FilterBar({
|
||||
selectedType,
|
||||
selectedStatus,
|
||||
selectedPriority,
|
||||
onTypeChange,
|
||||
onStatusChange,
|
||||
onPriorityChange,
|
||||
onClear
|
||||
}: {
|
||||
selectedType: DSRType | 'all'
|
||||
selectedStatus: DSRStatus | 'all'
|
||||
selectedPriority: string
|
||||
onTypeChange: (type: DSRType | 'all') => void
|
||||
onStatusChange: (status: DSRStatus | 'all') => void
|
||||
onPriorityChange: (priority: string) => void
|
||||
onClear: () => void
|
||||
}) {
|
||||
const hasFilters = selectedType !== 'all' || selectedStatus !== 'all' || selectedPriority !== 'all'
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<span className="text-sm text-gray-500">Filter:</span>
|
||||
|
||||
{/* Type Filter */}
|
||||
<select
|
||||
value={selectedType}
|
||||
onChange={(e) => onTypeChange(e.target.value as DSRType | '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 Typen</option>
|
||||
{Object.entries(DSR_TYPE_INFO).map(([type, info]) => (
|
||||
<option key={type} value={type}>{info.article} - {info.labelShort}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Status Filter */}
|
||||
<select
|
||||
value={selectedStatus}
|
||||
onChange={(e) => onStatusChange(e.target.value as DSRStatus | '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(DSR_STATUS_INFO).map(([status, info]) => (
|
||||
<option key={status} value={status}>{info.label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Priority Filter */}
|
||||
<select
|
||||
value={selectedPriority}
|
||||
onChange={(e) => onPriorityChange(e.target.value)}
|
||||
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 Prioritaeten</option>
|
||||
<option value="critical">Kritisch</option>
|
||||
<option value="high">Hoch</option>
|
||||
<option value="normal">Normal</option>
|
||||
<option value="low">Niedrig</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>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN PAGE
|
||||
// =============================================================================
|
||||
|
||||
export default function DSRPage() {
|
||||
const { state } = useSDK()
|
||||
const [activeTab, setActiveTab] = useState<TabId>('overview')
|
||||
const [requests, setRequests] = useState<DSRRequest[]>([])
|
||||
const [statistics, setStatistics] = useState<DSRStatistics | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
// Filters
|
||||
const [selectedType, setSelectedType] = useState<DSRType | 'all'>('all')
|
||||
const [selectedStatus, setSelectedStatus] = useState<DSRStatus | 'all'>('all')
|
||||
const [selectedPriority, setSelectedPriority] = useState<string>('all')
|
||||
|
||||
// Load data from SDK backend
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const { requests: dsrRequests, statistics: dsrStats } = await fetchSDKDSRList()
|
||||
setRequests(dsrRequests)
|
||||
setStatistics(dsrStats)
|
||||
} catch (error) {
|
||||
console.error('Failed to load DSR data:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
loadData()
|
||||
}, [])
|
||||
|
||||
// Calculate tab counts
|
||||
const tabCounts = useMemo(() => {
|
||||
return {
|
||||
intake: requests.filter(r => r.status === 'intake' || r.status === 'identity_verification').length,
|
||||
processing: requests.filter(r => r.status === 'processing').length,
|
||||
completed: requests.filter(r => r.status === 'completed' || r.status === 'rejected' || r.status === 'cancelled').length,
|
||||
overdue: requests.filter(r => isOverdue(r)).length
|
||||
}
|
||||
}, [requests])
|
||||
|
||||
// Filter requests based on active tab and filters
|
||||
const filteredRequests = useMemo(() => {
|
||||
let filtered = [...requests]
|
||||
|
||||
// Tab-based filtering
|
||||
if (activeTab === 'intake') {
|
||||
filtered = filtered.filter(r => r.status === 'intake' || r.status === 'identity_verification')
|
||||
} else if (activeTab === 'processing') {
|
||||
filtered = filtered.filter(r => r.status === 'processing')
|
||||
} else if (activeTab === 'completed') {
|
||||
filtered = filtered.filter(r => r.status === 'completed' || r.status === 'rejected' || r.status === 'cancelled')
|
||||
}
|
||||
|
||||
// Type filter
|
||||
if (selectedType !== 'all') {
|
||||
filtered = filtered.filter(r => r.type === selectedType)
|
||||
}
|
||||
|
||||
// Status filter
|
||||
if (selectedStatus !== 'all') {
|
||||
filtered = filtered.filter(r => r.status === selectedStatus)
|
||||
}
|
||||
|
||||
// Priority filter
|
||||
if (selectedPriority !== 'all') {
|
||||
filtered = filtered.filter(r => r.priority === selectedPriority)
|
||||
}
|
||||
|
||||
// Sort by urgency
|
||||
return filtered.sort((a, b) => {
|
||||
const getUrgency = (r: DSRRequest) => {
|
||||
if (r.status === 'completed' || r.status === 'rejected' || r.status === 'cancelled') return 100
|
||||
const days = getDaysRemaining(r.deadline.currentDeadline)
|
||||
if (days < 0) return -100 + days // Overdue items first
|
||||
return days
|
||||
}
|
||||
return getUrgency(a) - getUrgency(b)
|
||||
})
|
||||
}, [requests, activeTab, selectedType, selectedStatus, selectedPriority])
|
||||
|
||||
const tabs: Tab[] = [
|
||||
{ id: 'overview', label: 'Uebersicht' },
|
||||
{ id: 'intake', label: 'Eingang', count: tabCounts.intake, countColor: 'bg-blue-100 text-blue-600' },
|
||||
{ id: 'processing', label: 'In Bearbeitung', count: tabCounts.processing, countColor: 'bg-yellow-100 text-yellow-600' },
|
||||
{ id: 'completed', label: 'Abgeschlossen', count: tabCounts.completed, countColor: 'bg-green-100 text-green-600' },
|
||||
{ id: 'settings', label: 'Einstellungen' }
|
||||
]
|
||||
|
||||
const stepInfo = STEP_EXPLANATIONS['dsr']
|
||||
|
||||
const clearFilters = () => {
|
||||
setSelectedType('all')
|
||||
setSelectedStatus('all')
|
||||
setSelectedPriority('all')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Step Header */}
|
||||
<StepHeader
|
||||
stepId="dsr"
|
||||
title={stepInfo.title}
|
||||
description={stepInfo.description}
|
||||
explanation={stepInfo.explanation}
|
||||
tips={stepInfo.tips}
|
||||
>
|
||||
<Link
|
||||
href="/sdk/dsr/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>
|
||||
Anfrage erfassen
|
||||
</Link>
|
||||
</StepHeader>
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<TabNavigation
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
/>
|
||||
|
||||
{/* Loading State */}
|
||||
{isLoading ? (
|
||||
<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>
|
||||
) : activeTab === 'settings' ? (
|
||||
/* Settings Tab */
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-8 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="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.065 2.572c1.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.572 1.065c-.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.065-2.572c-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>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Einstellungen</h3>
|
||||
<p className="mt-2 text-gray-500">
|
||||
DSR-Portal-Einstellungen, E-Mail-Vorlagen und Workflow-Konfiguration
|
||||
werden in einer spaeteren Version verfuegbar sein.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Statistics (Overview Tab) */}
|
||||
{activeTab === 'overview' && statistics && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
label="Gesamt"
|
||||
value={statistics.total}
|
||||
color="gray"
|
||||
/>
|
||||
<StatCard
|
||||
label="Neue Anfragen"
|
||||
value={statistics.byStatus.intake + statistics.byStatus.identity_verification}
|
||||
color="blue"
|
||||
/>
|
||||
<StatCard
|
||||
label="In Bearbeitung"
|
||||
value={statistics.byStatus.processing}
|
||||
color="yellow"
|
||||
/>
|
||||
<StatCard
|
||||
label="Ueberfaellig"
|
||||
value={tabCounts.overdue}
|
||||
color={tabCounts.overdue > 0 ? 'red' : 'green'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Overdue Alert */}
|
||||
{tabCounts.overdue > 0 && (
|
||||
<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: {tabCounts.overdue} ueberfaellige Anfrage(n)
|
||||
</h4>
|
||||
<p className="text-sm text-red-600">
|
||||
Die gesetzliche Frist ist abgelaufen. Handeln Sie umgehend, um Bussgelder zu vermeiden.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setActiveTab('overview')
|
||||
setSelectedStatus('all')
|
||||
}}
|
||||
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 (Overview Tab) */}
|
||||
{activeTab === 'overview' && (
|
||||
<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">Fristen beachten</h4>
|
||||
<p className="text-sm text-blue-600 mt-1">
|
||||
Nach Art. 12 DSGVO muessen Anfragen innerhalb von einem Monat beantwortet werden.
|
||||
Eine Verlaengerung um zwei weitere Monate ist bei komplexen Anfragen moeglich,
|
||||
sofern der Betroffene innerhalb eines Monats darueber informiert wird.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<FilterBar
|
||||
selectedType={selectedType}
|
||||
selectedStatus={selectedStatus}
|
||||
selectedPriority={selectedPriority}
|
||||
onTypeChange={setSelectedType}
|
||||
onStatusChange={setSelectedStatus}
|
||||
onPriorityChange={setSelectedPriority}
|
||||
onClear={clearFilters}
|
||||
/>
|
||||
|
||||
{/* Requests List */}
|
||||
<div className="space-y-4">
|
||||
{filteredRequests.map(request => (
|
||||
<RequestCard key={request.id} request={request} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Empty State */}
|
||||
{filteredRequests.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 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 2" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Keine Anfragen gefunden</h3>
|
||||
<p className="mt-2 text-gray-500">
|
||||
{selectedType !== 'all' || selectedStatus !== 'all' || selectedPriority !== 'all'
|
||||
? 'Passen Sie die Filter an oder'
|
||||
: 'Es sind noch keine Anfragen vorhanden.'
|
||||
}
|
||||
</p>
|
||||
{(selectedType !== 'all' || selectedStatus !== 'all' || selectedPriority !== 'all') ? (
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
className="mt-4 px-4 py-2 text-purple-600 hover:bg-purple-50 rounded-lg transition-colors"
|
||||
>
|
||||
Filter zuruecksetzen
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
href="/sdk/dsr/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>
|
||||
Erste Anfrage erfassen
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,763 +0,0 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Cookie Banner Configuration Page
|
||||
*
|
||||
* Konfiguriert den Cookie-Banner basierend auf dem Datenpunktkatalog.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useSDK } from '@/lib/sdk'
|
||||
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
||||
import {
|
||||
EinwilligungenProvider,
|
||||
useEinwilligungen,
|
||||
} from '@/lib/sdk/einwilligungen/context'
|
||||
import {
|
||||
generateCookieBannerConfig,
|
||||
generateEmbedCode,
|
||||
DEFAULT_COOKIE_BANNER_TEXTS,
|
||||
DEFAULT_COOKIE_BANNER_STYLING,
|
||||
} from '@/lib/sdk/einwilligungen/generator/cookie-banner'
|
||||
import {
|
||||
CookieBannerConfig,
|
||||
CookieBannerStyling,
|
||||
CookieBannerTexts,
|
||||
SupportedLanguage,
|
||||
} from '@/lib/sdk/einwilligungen/types'
|
||||
import {
|
||||
Cookie,
|
||||
Settings,
|
||||
Palette,
|
||||
Code,
|
||||
Copy,
|
||||
Check,
|
||||
Eye,
|
||||
ArrowLeft,
|
||||
Monitor,
|
||||
Smartphone,
|
||||
Tablet,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
ExternalLink,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
// =============================================================================
|
||||
// STYLING FORM
|
||||
// =============================================================================
|
||||
|
||||
interface StylingFormProps {
|
||||
styling: CookieBannerStyling
|
||||
onChange: (styling: CookieBannerStyling) => void
|
||||
}
|
||||
|
||||
function StylingForm({ styling, onChange }: StylingFormProps) {
|
||||
const handleChange = (field: keyof CookieBannerStyling, value: string | number) => {
|
||||
onChange({ ...styling, [field]: value })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Position */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
Position
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{(['BOTTOM', 'TOP', 'CENTER'] as const).map((pos) => (
|
||||
<button
|
||||
key={pos}
|
||||
onClick={() => handleChange('position', pos)}
|
||||
className={`px-4 py-2 text-sm rounded-lg border transition-colors ${
|
||||
styling.position === pos
|
||||
? 'bg-indigo-50 border-indigo-300 text-indigo-700'
|
||||
: 'bg-white border-slate-200 text-slate-600 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
{pos === 'BOTTOM' ? 'Unten' : pos === 'TOP' ? 'Oben' : 'Zentriert'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Theme */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
Theme
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{(['LIGHT', 'DARK'] as const).map((theme) => (
|
||||
<button
|
||||
key={theme}
|
||||
onClick={() => handleChange('theme', theme)}
|
||||
className={`px-4 py-2 text-sm rounded-lg border transition-colors ${
|
||||
styling.theme === theme
|
||||
? 'bg-indigo-50 border-indigo-300 text-indigo-700'
|
||||
: 'bg-white border-slate-200 text-slate-600 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
{theme === 'LIGHT' ? 'Hell' : 'Dunkel'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Colors */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Primaerfarbe
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={styling.primaryColor}
|
||||
onChange={(e) => handleChange('primaryColor', e.target.value)}
|
||||
className="w-10 h-10 rounded border border-slate-200 cursor-pointer"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={styling.primaryColor}
|
||||
onChange={(e) => handleChange('primaryColor', e.target.value)}
|
||||
className="flex-1 px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Sekundaerfarbe
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={styling.secondaryColor || '#f1f5f9'}
|
||||
onChange={(e) => handleChange('secondaryColor', e.target.value)}
|
||||
className="w-10 h-10 rounded border border-slate-200 cursor-pointer"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={styling.secondaryColor || '#f1f5f9'}
|
||||
onChange={(e) => handleChange('secondaryColor', e.target.value)}
|
||||
className="flex-1 px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Border Radius & Max Width */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Eckenradius (px)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={32}
|
||||
value={styling.borderRadius}
|
||||
onChange={(e) => handleChange('borderRadius', parseInt(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">
|
||||
Max. Breite (px)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={320}
|
||||
max={800}
|
||||
value={styling.maxWidth}
|
||||
onChange={(e) => handleChange('maxWidth', parseInt(e.target.value))}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// TEXTS FORM
|
||||
// =============================================================================
|
||||
|
||||
interface TextsFormProps {
|
||||
texts: CookieBannerTexts
|
||||
language: SupportedLanguage
|
||||
onChange: (texts: CookieBannerTexts) => void
|
||||
}
|
||||
|
||||
function TextsForm({ texts, language, onChange }: TextsFormProps) {
|
||||
const handleChange = (field: keyof CookieBannerTexts, value: string) => {
|
||||
onChange({
|
||||
...texts,
|
||||
[field]: { ...texts[field], [language]: value },
|
||||
})
|
||||
}
|
||||
|
||||
const fields: { key: keyof CookieBannerTexts; label: string; multiline?: boolean }[] = [
|
||||
{ key: 'title', label: 'Titel' },
|
||||
{ key: 'description', label: 'Beschreibung', multiline: true },
|
||||
{ key: 'acceptAll', label: 'Alle akzeptieren Button' },
|
||||
{ key: 'rejectAll', label: 'Nur notwendige Button' },
|
||||
{ key: 'customize', label: 'Einstellungen Button' },
|
||||
{ key: 'save', label: 'Speichern Button' },
|
||||
{ key: 'privacyPolicyLink', label: 'Datenschutz-Link Text' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{fields.map(({ key, label, multiline }) => (
|
||||
<div key={key}>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
{label}
|
||||
</label>
|
||||
{multiline ? (
|
||||
<textarea
|
||||
value={texts[key][language]}
|
||||
onChange={(e) => handleChange(key, e.target.value)}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
value={texts[key][language]}
|
||||
onChange={(e) => handleChange(key, e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// BANNER PREVIEW
|
||||
// =============================================================================
|
||||
|
||||
interface BannerPreviewProps {
|
||||
config: CookieBannerConfig | null
|
||||
language: SupportedLanguage
|
||||
device: 'desktop' | 'tablet' | 'mobile'
|
||||
}
|
||||
|
||||
function BannerPreview({ config, language, device }: BannerPreviewProps) {
|
||||
const [showDetails, setShowDetails] = useState(false)
|
||||
|
||||
if (!config) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64 bg-slate-100 rounded-xl">
|
||||
<p className="text-slate-400">Konfiguration wird geladen...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const isDark = config.styling.theme === 'DARK'
|
||||
const bgColor = isDark ? '#1e293b' : config.styling.backgroundColor || '#ffffff'
|
||||
const textColor = isDark ? '#f1f5f9' : config.styling.textColor || '#1e293b'
|
||||
|
||||
const deviceWidths = {
|
||||
desktop: '100%',
|
||||
tablet: '768px',
|
||||
mobile: '375px',
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="border rounded-xl overflow-hidden"
|
||||
style={{
|
||||
maxWidth: deviceWidths[device],
|
||||
margin: '0 auto',
|
||||
}}
|
||||
>
|
||||
{/* Simulated Browser */}
|
||||
<div className="bg-slate-100 h-8 flex items-center px-3 gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-red-400" />
|
||||
<div className="w-3 h-3 rounded-full bg-yellow-400" />
|
||||
<div className="w-3 h-3 rounded-full bg-green-400" />
|
||||
<div className="flex-1 bg-white rounded h-5 mx-4" />
|
||||
</div>
|
||||
|
||||
{/* Page Content */}
|
||||
<div className="relative bg-slate-50 min-h-[400px]">
|
||||
{/* Placeholder Content */}
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="h-4 bg-slate-200 rounded w-3/4" />
|
||||
<div className="h-4 bg-slate-200 rounded w-1/2" />
|
||||
<div className="h-32 bg-slate-200 rounded" />
|
||||
<div className="h-4 bg-slate-200 rounded w-2/3" />
|
||||
<div className="h-4 bg-slate-200 rounded w-1/2" />
|
||||
</div>
|
||||
|
||||
{/* Overlay */}
|
||||
<div className="absolute inset-0 bg-black/40" />
|
||||
|
||||
{/* Cookie Banner */}
|
||||
<div
|
||||
className={`absolute ${
|
||||
config.styling.position === 'TOP'
|
||||
? 'top-0 left-0 right-0'
|
||||
: config.styling.position === 'CENTER'
|
||||
? 'top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2'
|
||||
: 'bottom-0 left-0 right-0'
|
||||
}`}
|
||||
style={{
|
||||
maxWidth: config.styling.maxWidth,
|
||||
margin: config.styling.position === 'CENTER' ? '0' : '16px auto',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="shadow-xl"
|
||||
style={{
|
||||
background: bgColor,
|
||||
color: textColor,
|
||||
borderRadius: config.styling.borderRadius,
|
||||
padding: '20px',
|
||||
}}
|
||||
>
|
||||
<h3 className="font-semibold text-lg mb-2">
|
||||
{config.texts.title[language]}
|
||||
</h3>
|
||||
<p className="text-sm opacity-80 mb-4">
|
||||
{config.texts.description[language]}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
<button
|
||||
style={{ background: config.styling.secondaryColor }}
|
||||
className="flex-1 min-w-[100px] px-4 py-2 rounded-lg text-sm font-medium"
|
||||
>
|
||||
{config.texts.rejectAll[language]}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowDetails(!showDetails)}
|
||||
style={{ background: config.styling.secondaryColor }}
|
||||
className="flex-1 min-w-[100px] px-4 py-2 rounded-lg text-sm font-medium"
|
||||
>
|
||||
{config.texts.customize[language]}
|
||||
</button>
|
||||
<button
|
||||
style={{ background: config.styling.primaryColor, color: 'white' }}
|
||||
className="flex-1 min-w-[100px] px-4 py-2 rounded-lg text-sm font-medium"
|
||||
>
|
||||
{config.texts.acceptAll[language]}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showDetails && (
|
||||
<div className="border-t pt-3 mt-3 space-y-2" style={{ borderColor: 'rgba(128,128,128,0.2)' }}>
|
||||
{config.categories.map((cat) => (
|
||||
<div key={cat.id} className="flex items-center justify-between py-2">
|
||||
<div>
|
||||
<div className="font-medium text-sm">{cat.name[language]}</div>
|
||||
<div className="text-xs opacity-60">{cat.description[language]}</div>
|
||||
</div>
|
||||
<div
|
||||
className={`w-10 h-6 rounded-full relative ${
|
||||
cat.isRequired || cat.defaultEnabled
|
||||
? ''
|
||||
: 'opacity-50'
|
||||
}`}
|
||||
style={{
|
||||
background: cat.isRequired || cat.defaultEnabled
|
||||
? config.styling.primaryColor
|
||||
: 'rgba(128,128,128,0.3)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute top-1 w-4 h-4 bg-white rounded-full transition-all"
|
||||
style={{
|
||||
left: cat.isRequired || cat.defaultEnabled ? '20px' : '4px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
style={{ background: config.styling.primaryColor, color: 'white' }}
|
||||
className="w-full px-4 py-2 rounded-lg text-sm font-medium mt-2"
|
||||
>
|
||||
{config.texts.save[language]}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<a
|
||||
href="#"
|
||||
className="block text-xs mt-3"
|
||||
style={{ color: config.styling.primaryColor }}
|
||||
>
|
||||
{config.texts.privacyPolicyLink[language]}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// EMBED CODE VIEWER
|
||||
// =============================================================================
|
||||
|
||||
interface EmbedCodeViewerProps {
|
||||
config: CookieBannerConfig | null
|
||||
}
|
||||
|
||||
function EmbedCodeViewer({ config }: EmbedCodeViewerProps) {
|
||||
const [activeTab, setActiveTab] = useState<'script' | 'html' | 'css' | 'js'>('script')
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const embedCode = useMemo(() => {
|
||||
if (!config) return null
|
||||
return generateEmbedCode(config, '/datenschutz')
|
||||
}, [config])
|
||||
|
||||
const copyToClipboard = async (text: string) => {
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
if (!embedCode) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-48 bg-slate-100 rounded-xl">
|
||||
<p className="text-slate-400">Embed-Code wird generiert...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ id: 'script', label: 'Script-Tag', content: embedCode.scriptTag },
|
||||
{ id: 'html', label: 'HTML', content: embedCode.html },
|
||||
{ id: 'css', label: 'CSS', content: embedCode.css },
|
||||
{ id: 'js', label: 'JavaScript', content: embedCode.js },
|
||||
] as const
|
||||
|
||||
const currentContent = tabs.find((t) => t.id === activeTab)?.content || ''
|
||||
|
||||
return (
|
||||
<div className="border border-slate-200 rounded-xl overflow-hidden">
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-slate-200 bg-slate-50">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'bg-white text-indigo-600 border-b-2 border-indigo-600 -mb-px'
|
||||
: 'text-slate-600 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Code */}
|
||||
<div className="relative">
|
||||
<pre className="p-4 bg-slate-900 text-slate-100 text-sm font-mono overflow-x-auto max-h-[400px]">
|
||||
{currentContent}
|
||||
</pre>
|
||||
<button
|
||||
onClick={() => copyToClipboard(currentContent)}
|
||||
className="absolute top-3 right-3 flex items-center gap-1.5 px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-200 rounded-lg text-xs"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="w-3.5 h-3.5 text-green-400" />
|
||||
Kopiert
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
Kopieren
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Integration Instructions */}
|
||||
{activeTab === 'script' && (
|
||||
<div className="p-4 bg-amber-50 border-t border-amber-200">
|
||||
<p className="text-sm text-amber-800">
|
||||
<strong>Integration:</strong> Fuegen Sie den Script-Tag in den{' '}
|
||||
<code className="bg-amber-100 px-1 rounded"><head></code> oder vor dem
|
||||
schliessenden{' '}
|
||||
<code className="bg-amber-100 px-1 rounded"></body></code>-Tag ein.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CATEGORY LIST
|
||||
// =============================================================================
|
||||
|
||||
interface CategoryListProps {
|
||||
config: CookieBannerConfig | null
|
||||
language: SupportedLanguage
|
||||
}
|
||||
|
||||
function CategoryList({ config, language }: CategoryListProps) {
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set())
|
||||
|
||||
if (!config) return null
|
||||
|
||||
const toggleCategory = (id: string) => {
|
||||
setExpandedCategories((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) {
|
||||
next.delete(id)
|
||||
} else {
|
||||
next.add(id)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{config.categories.map((cat) => {
|
||||
const isExpanded = expandedCategories.has(cat.id)
|
||||
return (
|
||||
<div key={cat.id} className="border border-slate-200 rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => toggleCategory(cat.id)}
|
||||
className="w-full flex items-center justify-between p-4 hover:bg-slate-50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full ${
|
||||
cat.isRequired ? 'bg-green-500' : 'bg-amber-500'
|
||||
}`}
|
||||
/>
|
||||
<div className="text-left">
|
||||
<div className="font-medium text-slate-900">{cat.name[language]}</div>
|
||||
<div className="text-sm text-slate-500">
|
||||
{cat.cookies.length} Cookie(s) | {cat.dataPointIds.length} Datenpunkt(e)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{cat.isRequired && (
|
||||
<span className="px-2 py-0.5 text-xs bg-green-100 text-green-700 rounded-full">
|
||||
Erforderlich
|
||||
</span>
|
||||
)}
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-5 h-5 text-slate-400" />
|
||||
) : (
|
||||
<ChevronRight className="w-5 h-5 text-slate-400" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="px-4 pb-4 border-t border-slate-100 bg-slate-50">
|
||||
<p className="text-sm text-slate-600 py-3">{cat.description[language]}</p>
|
||||
|
||||
{cat.cookies.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xs font-semibold text-slate-500 uppercase">Cookies</h4>
|
||||
<div className="space-y-1">
|
||||
{cat.cookies.map((cookie, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex items-center justify-between p-2 bg-white rounded border border-slate-200"
|
||||
>
|
||||
<div>
|
||||
<span className="font-mono text-sm text-slate-700">{cookie.name}</span>
|
||||
<span className="text-xs text-slate-400 ml-2">({cookie.provider})</span>
|
||||
</div>
|
||||
<span className="text-xs text-slate-500">{cookie.expiry}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN CONTENT
|
||||
// =============================================================================
|
||||
|
||||
function CookieBannerContent() {
|
||||
const { state } = useSDK()
|
||||
const { allDataPoints } = useEinwilligungen()
|
||||
|
||||
const [styling, setStyling] = useState<CookieBannerStyling>(DEFAULT_COOKIE_BANNER_STYLING)
|
||||
const [texts, setTexts] = useState<CookieBannerTexts>(DEFAULT_COOKIE_BANNER_TEXTS)
|
||||
const [language, setLanguage] = useState<SupportedLanguage>('de')
|
||||
const [activeTab, setActiveTab] = useState<'styling' | 'texts' | 'embed' | 'categories'>('styling')
|
||||
const [device, setDevice] = useState<'desktop' | 'tablet' | 'mobile'>('desktop')
|
||||
|
||||
const config = useMemo(() => {
|
||||
return generateCookieBannerConfig(
|
||||
state.tenantId || 'demo',
|
||||
allDataPoints,
|
||||
texts,
|
||||
styling
|
||||
)
|
||||
}, [state.tenantId, allDataPoints, texts, styling])
|
||||
|
||||
const cookieDataPoints = useMemo(
|
||||
() => allDataPoints.filter((dp) => dp.cookieCategory !== null),
|
||||
[allDataPoints]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Back Link */}
|
||||
<Link
|
||||
href="/sdk/einwilligungen/catalog"
|
||||
className="inline-flex items-center gap-2 text-sm text-slate-600 hover:text-slate-900"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Zurueck zum Katalog
|
||||
</Link>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">Cookie-Banner Konfiguration</h1>
|
||||
<p className="text-slate-600 mt-1">
|
||||
Konfigurieren Sie Ihren DSGVO-konformen Cookie-Banner.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={language}
|
||||
onChange={(e) => setLanguage(e.target.value as SupportedLanguage)}
|
||||
className="px-3 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
>
|
||||
<option value="de">Deutsch</option>
|
||||
<option value="en">English</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<div className="text-sm text-slate-500">Kategorien</div>
|
||||
<div className="text-2xl font-bold text-slate-900">{config?.categories.length || 0}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<div className="text-sm text-slate-500">Cookie-Datenpunkte</div>
|
||||
<div className="text-2xl font-bold text-indigo-600">{cookieDataPoints.length}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-green-200 p-4">
|
||||
<div className="text-sm text-green-600">Erforderlich</div>
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{config?.categories.filter((c) => c.isRequired).length || 0}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-amber-200 p-4">
|
||||
<div className="text-sm text-amber-600">Optional</div>
|
||||
<div className="text-2xl font-bold text-amber-600">
|
||||
{config?.categories.filter((c) => !c.isRequired).length || 0}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Two Column Layout */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Left: Configuration */}
|
||||
<div className="space-y-4">
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-slate-200">
|
||||
{[
|
||||
{ id: 'styling', label: 'Design', icon: Palette },
|
||||
{ id: 'texts', label: 'Texte', icon: Settings },
|
||||
{ id: 'categories', label: 'Kategorien', icon: Cookie },
|
||||
{ id: 'embed', label: 'Embed-Code', icon: Code },
|
||||
].map(({ id, label, icon: Icon }) => (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => setActiveTab(id as typeof activeTab)}
|
||||
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === id
|
||||
? 'text-indigo-600 border-indigo-600'
|
||||
: 'text-slate-600 border-transparent hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
{activeTab === 'styling' && (
|
||||
<StylingForm styling={styling} onChange={setStyling} />
|
||||
)}
|
||||
{activeTab === 'texts' && (
|
||||
<TextsForm texts={texts} language={language} onChange={setTexts} />
|
||||
)}
|
||||
{activeTab === 'categories' && (
|
||||
<CategoryList config={config} language={language} />
|
||||
)}
|
||||
{activeTab === 'embed' && <EmbedCodeViewer config={config} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Preview */}
|
||||
<div className="space-y-4">
|
||||
{/* Device Selector */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-semibold text-slate-900">Vorschau</h3>
|
||||
<div className="flex items-center border border-slate-200 rounded-lg overflow-hidden">
|
||||
{[
|
||||
{ id: 'desktop', icon: Monitor },
|
||||
{ id: 'tablet', icon: Tablet },
|
||||
{ id: 'mobile', icon: Smartphone },
|
||||
].map(({ id, icon: Icon }) => (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => setDevice(id as typeof device)}
|
||||
className={`p-2 ${
|
||||
device === id
|
||||
? 'bg-indigo-50 text-indigo-600'
|
||||
: 'text-slate-400 hover:text-slate-600'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-5 h-5" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
<BannerPreview config={config} language={language} device={device} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN PAGE
|
||||
// =============================================================================
|
||||
|
||||
export default function CookieBannerPage() {
|
||||
return (
|
||||
<EinwilligungenProvider>
|
||||
<CookieBannerContent />
|
||||
</EinwilligungenProvider>
|
||||
)
|
||||
}
|
||||
@@ -1,931 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useSDK } from '@/lib/sdk'
|
||||
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
||||
import {
|
||||
Database,
|
||||
FileText,
|
||||
Cookie,
|
||||
Clock,
|
||||
LayoutGrid,
|
||||
X,
|
||||
History,
|
||||
Shield,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Monitor,
|
||||
Globe,
|
||||
Calendar,
|
||||
User,
|
||||
FileCheck,
|
||||
} from 'lucide-react'
|
||||
|
||||
// =============================================================================
|
||||
// NAVIGATION TABS
|
||||
// =============================================================================
|
||||
|
||||
const EINWILLIGUNGEN_TABS = [
|
||||
{
|
||||
id: 'overview',
|
||||
label: 'Übersicht',
|
||||
href: '/sdk/einwilligungen',
|
||||
icon: LayoutGrid,
|
||||
description: 'Consent-Tracking Dashboard',
|
||||
},
|
||||
{
|
||||
id: 'catalog',
|
||||
label: 'Datenpunktkatalog',
|
||||
href: '/sdk/einwilligungen/catalog',
|
||||
icon: Database,
|
||||
description: '18 Kategorien, 128 Datenpunkte',
|
||||
},
|
||||
{
|
||||
id: 'privacy-policy',
|
||||
label: 'DSI Generator',
|
||||
href: '/sdk/einwilligungen/privacy-policy',
|
||||
icon: FileText,
|
||||
description: 'Datenschutzinformation erstellen',
|
||||
},
|
||||
{
|
||||
id: 'cookie-banner',
|
||||
label: 'Cookie-Banner',
|
||||
href: '/sdk/einwilligungen/cookie-banner',
|
||||
icon: Cookie,
|
||||
description: 'Cookie-Consent konfigurieren',
|
||||
},
|
||||
{
|
||||
id: 'retention',
|
||||
label: 'Löschmatrix',
|
||||
href: '/sdk/einwilligungen/retention',
|
||||
icon: Clock,
|
||||
description: 'Aufbewahrungsfristen verwalten',
|
||||
},
|
||||
]
|
||||
|
||||
function EinwilligungenNavTabs() {
|
||||
const pathname = usePathname()
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-2 mb-6">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{EINWILLIGUNGEN_TABS.map((tab) => {
|
||||
const Icon = tab.icon
|
||||
const isActive = pathname === tab.href
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={tab.id}
|
||||
href={tab.href}
|
||||
className={`flex items-center gap-3 px-4 py-3 rounded-lg transition-all ${
|
||||
isActive
|
||||
? 'bg-purple-100 text-purple-900 shadow-sm'
|
||||
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
<Icon className={`w-5 h-5 ${isActive ? 'text-purple-600' : 'text-gray-400'}`} />
|
||||
<div>
|
||||
<div className={`font-medium text-sm ${isActive ? 'text-purple-900' : ''}`}>
|
||||
{tab.label}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">{tab.description}</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
type ConsentType = 'marketing' | 'analytics' | 'newsletter' | 'terms' | 'privacy' | 'cookies'
|
||||
type ConsentStatus = 'granted' | 'withdrawn' | 'pending'
|
||||
type HistoryAction = 'granted' | 'withdrawn' | 'version_update' | 'renewed'
|
||||
|
||||
interface ConsentHistoryEntry {
|
||||
id: string
|
||||
action: HistoryAction
|
||||
timestamp: Date
|
||||
version: string
|
||||
documentTitle?: string
|
||||
ipAddress: string
|
||||
userAgent: string
|
||||
source: string
|
||||
notes?: string
|
||||
}
|
||||
|
||||
interface ConsentRecord {
|
||||
id: string
|
||||
odentifier: string
|
||||
email: string
|
||||
firstName?: string
|
||||
lastName?: string
|
||||
consentType: ConsentType
|
||||
status: ConsentStatus
|
||||
currentVersion: string
|
||||
grantedAt: Date | null
|
||||
withdrawnAt: Date | null
|
||||
source: string
|
||||
ipAddress: string
|
||||
userAgent: string
|
||||
history: ConsentHistoryEntry[]
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MOCK DATA WITH HISTORY
|
||||
// =============================================================================
|
||||
|
||||
const mockRecords: ConsentRecord[] = [
|
||||
{
|
||||
id: 'c-1',
|
||||
odentifier: 'usr-001',
|
||||
email: 'max.mustermann@example.de',
|
||||
firstName: 'Max',
|
||||
lastName: 'Mustermann',
|
||||
consentType: 'terms',
|
||||
status: 'granted',
|
||||
currentVersion: '2.1',
|
||||
grantedAt: new Date('2024-01-15T10:23:45'),
|
||||
withdrawnAt: null,
|
||||
source: 'Website-Formular',
|
||||
ipAddress: '192.168.1.45',
|
||||
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
|
||||
history: [
|
||||
{
|
||||
id: 'h-1-1',
|
||||
action: 'granted',
|
||||
timestamp: new Date('2023-06-01T14:30:00'),
|
||||
version: '1.0',
|
||||
documentTitle: 'AGB Version 1.0',
|
||||
ipAddress: '192.168.1.42',
|
||||
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0)',
|
||||
source: 'App-Registrierung',
|
||||
},
|
||||
{
|
||||
id: 'h-1-2',
|
||||
action: 'version_update',
|
||||
timestamp: new Date('2023-09-15T09:15:00'),
|
||||
version: '1.5',
|
||||
documentTitle: 'AGB Version 1.5 - DSGVO Update',
|
||||
ipAddress: '192.168.1.43',
|
||||
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)',
|
||||
source: 'E-Mail Bestätigung',
|
||||
notes: 'Nutzer hat neuen AGB nach DSGVO-Anpassung zugestimmt',
|
||||
},
|
||||
{
|
||||
id: 'h-1-3',
|
||||
action: 'version_update',
|
||||
timestamp: new Date('2024-01-15T10:23:45'),
|
||||
version: '2.1',
|
||||
documentTitle: 'AGB Version 2.1 - KI-Klauseln',
|
||||
ipAddress: '192.168.1.45',
|
||||
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
|
||||
source: 'Website-Formular',
|
||||
notes: 'Zustimmung zu neuen KI-Nutzungsbedingungen',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'c-2',
|
||||
odentifier: 'usr-001',
|
||||
email: 'max.mustermann@example.de',
|
||||
firstName: 'Max',
|
||||
lastName: 'Mustermann',
|
||||
consentType: 'marketing',
|
||||
status: 'granted',
|
||||
currentVersion: '1.3',
|
||||
grantedAt: new Date('2024-01-15T10:23:45'),
|
||||
withdrawnAt: null,
|
||||
source: 'Website-Formular',
|
||||
ipAddress: '192.168.1.45',
|
||||
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
|
||||
history: [
|
||||
{
|
||||
id: 'h-2-1',
|
||||
action: 'granted',
|
||||
timestamp: new Date('2024-01-15T10:23:45'),
|
||||
version: '1.3',
|
||||
ipAddress: '192.168.1.45',
|
||||
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
|
||||
source: 'Website-Formular',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'c-3',
|
||||
odentifier: 'usr-002',
|
||||
email: 'anna.schmidt@example.de',
|
||||
firstName: 'Anna',
|
||||
lastName: 'Schmidt',
|
||||
consentType: 'newsletter',
|
||||
status: 'withdrawn',
|
||||
currentVersion: '1.2',
|
||||
grantedAt: new Date('2023-11-20T16:45:00'),
|
||||
withdrawnAt: new Date('2024-01-10T08:30:00'),
|
||||
source: 'App',
|
||||
ipAddress: '10.0.0.88',
|
||||
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0)',
|
||||
history: [
|
||||
{
|
||||
id: 'h-3-1',
|
||||
action: 'granted',
|
||||
timestamp: new Date('2023-11-20T16:45:00'),
|
||||
version: '1.2',
|
||||
ipAddress: '10.0.0.88',
|
||||
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0)',
|
||||
source: 'App',
|
||||
},
|
||||
{
|
||||
id: 'h-3-2',
|
||||
action: 'withdrawn',
|
||||
timestamp: new Date('2024-01-10T08:30:00'),
|
||||
version: '1.2',
|
||||
ipAddress: '10.0.0.92',
|
||||
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_2)',
|
||||
source: 'Profil-Einstellungen',
|
||||
notes: 'Nutzer hat Newsletter-Abo über Profil deaktiviert',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'c-4',
|
||||
odentifier: 'usr-003',
|
||||
email: 'peter.meier@example.de',
|
||||
firstName: 'Peter',
|
||||
lastName: 'Meier',
|
||||
consentType: 'privacy',
|
||||
status: 'granted',
|
||||
currentVersion: '3.0',
|
||||
grantedAt: new Date('2024-01-20T11:00:00'),
|
||||
withdrawnAt: null,
|
||||
source: 'Cookie-Banner',
|
||||
ipAddress: '172.16.0.55',
|
||||
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||
history: [
|
||||
{
|
||||
id: 'h-4-1',
|
||||
action: 'granted',
|
||||
timestamp: new Date('2023-03-10T09:00:00'),
|
||||
version: '2.0',
|
||||
documentTitle: 'Datenschutzerklärung v2.0',
|
||||
ipAddress: '172.16.0.50',
|
||||
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
|
||||
source: 'Registrierung',
|
||||
},
|
||||
{
|
||||
id: 'h-4-2',
|
||||
action: 'version_update',
|
||||
timestamp: new Date('2023-08-01T14:00:00'),
|
||||
version: '2.5',
|
||||
documentTitle: 'Datenschutzerklärung v2.5 - Cookie-Update',
|
||||
ipAddress: '172.16.0.52',
|
||||
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
|
||||
source: 'Cookie-Banner',
|
||||
notes: 'Zustimmung nach Cookie-Richtlinien-Update',
|
||||
},
|
||||
{
|
||||
id: 'h-4-3',
|
||||
action: 'version_update',
|
||||
timestamp: new Date('2024-01-20T11:00:00'),
|
||||
version: '3.0',
|
||||
documentTitle: 'Datenschutzerklärung v3.0 - AI Act Compliance',
|
||||
ipAddress: '172.16.0.55',
|
||||
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||
source: 'Cookie-Banner',
|
||||
notes: 'Neue DSI mit AI Act Transparenzhinweisen',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'c-5',
|
||||
odentifier: 'usr-004',
|
||||
email: 'lisa.weber@example.de',
|
||||
firstName: 'Lisa',
|
||||
lastName: 'Weber',
|
||||
consentType: 'analytics',
|
||||
status: 'granted',
|
||||
currentVersion: '1.0',
|
||||
grantedAt: new Date('2024-01-18T13:22:00'),
|
||||
withdrawnAt: null,
|
||||
source: 'Cookie-Banner',
|
||||
ipAddress: '192.168.2.100',
|
||||
userAgent: 'Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36',
|
||||
history: [
|
||||
{
|
||||
id: 'h-5-1',
|
||||
action: 'granted',
|
||||
timestamp: new Date('2024-01-18T13:22:00'),
|
||||
version: '1.0',
|
||||
ipAddress: '192.168.2.100',
|
||||
userAgent: 'Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36',
|
||||
source: 'Cookie-Banner',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'c-6',
|
||||
odentifier: 'usr-005',
|
||||
email: 'thomas.klein@example.de',
|
||||
firstName: 'Thomas',
|
||||
lastName: 'Klein',
|
||||
consentType: 'cookies',
|
||||
status: 'granted',
|
||||
currentVersion: '1.8',
|
||||
grantedAt: new Date('2024-01-22T09:15:00'),
|
||||
withdrawnAt: null,
|
||||
source: 'Cookie-Banner',
|
||||
ipAddress: '10.1.0.200',
|
||||
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) Safari/605.1.15',
|
||||
history: [
|
||||
{
|
||||
id: 'h-6-1',
|
||||
action: 'granted',
|
||||
timestamp: new Date('2023-05-10T10:00:00'),
|
||||
version: '1.0',
|
||||
ipAddress: '10.1.0.150',
|
||||
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 13_0)',
|
||||
source: 'Cookie-Banner',
|
||||
},
|
||||
{
|
||||
id: 'h-6-2',
|
||||
action: 'withdrawn',
|
||||
timestamp: new Date('2023-08-20T15:30:00'),
|
||||
version: '1.0',
|
||||
ipAddress: '10.1.0.160',
|
||||
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 13_0)',
|
||||
source: 'Cookie-Einstellungen',
|
||||
notes: 'Nutzer hat alle Cookies abgelehnt',
|
||||
},
|
||||
{
|
||||
id: 'h-6-3',
|
||||
action: 'renewed',
|
||||
timestamp: new Date('2024-01-22T09:15:00'),
|
||||
version: '1.8',
|
||||
ipAddress: '10.1.0.200',
|
||||
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) Safari/605.1.15',
|
||||
source: 'Cookie-Banner',
|
||||
notes: 'Nutzer hat Cookies nach Banner-Redesign erneut akzeptiert',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// =============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// =============================================================================
|
||||
|
||||
const typeLabels: Record<ConsentType, string> = {
|
||||
marketing: 'Marketing',
|
||||
analytics: 'Analyse',
|
||||
newsletter: 'Newsletter',
|
||||
terms: 'AGB',
|
||||
privacy: 'Datenschutz',
|
||||
cookies: 'Cookies',
|
||||
}
|
||||
|
||||
const typeColors: Record<ConsentType, string> = {
|
||||
marketing: 'bg-purple-100 text-purple-700',
|
||||
analytics: 'bg-blue-100 text-blue-700',
|
||||
newsletter: 'bg-green-100 text-green-700',
|
||||
terms: 'bg-yellow-100 text-yellow-700',
|
||||
privacy: 'bg-orange-100 text-orange-700',
|
||||
cookies: 'bg-pink-100 text-pink-700',
|
||||
}
|
||||
|
||||
const statusColors: Record<ConsentStatus, string> = {
|
||||
granted: 'bg-green-100 text-green-700',
|
||||
withdrawn: 'bg-red-100 text-red-700',
|
||||
pending: 'bg-yellow-100 text-yellow-700',
|
||||
}
|
||||
|
||||
const statusLabels: Record<ConsentStatus, string> = {
|
||||
granted: 'Erteilt',
|
||||
withdrawn: 'Widerrufen',
|
||||
pending: 'Ausstehend',
|
||||
}
|
||||
|
||||
const actionLabels: Record<HistoryAction, string> = {
|
||||
granted: 'Einwilligung erteilt',
|
||||
withdrawn: 'Einwilligung widerrufen',
|
||||
version_update: 'Neue Version akzeptiert',
|
||||
renewed: 'Einwilligung erneuert',
|
||||
}
|
||||
|
||||
const actionIcons: Record<HistoryAction, React.ReactNode> = {
|
||||
granted: <CheckCircle className="w-5 h-5 text-green-500" />,
|
||||
withdrawn: <XCircle className="w-5 h-5 text-red-500" />,
|
||||
version_update: <FileCheck className="w-5 h-5 text-blue-500" />,
|
||||
renewed: <Shield className="w-5 h-5 text-purple-500" />,
|
||||
}
|
||||
|
||||
function formatDateTime(date: Date): string {
|
||||
return date.toLocaleString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function formatDate(date: Date | null): string {
|
||||
if (!date) return '-'
|
||||
return date.toLocaleDateString('de-DE')
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// DETAIL MODAL COMPONENT
|
||||
// =============================================================================
|
||||
|
||||
interface ConsentDetailModalProps {
|
||||
record: ConsentRecord
|
||||
onClose: () => void
|
||||
onRevoke: (recordId: string) => void
|
||||
}
|
||||
|
||||
function ConsentDetailModal({ record, onClose, onRevoke }: ConsentDetailModalProps) {
|
||||
const [showRevokeConfirm, setShowRevokeConfirm] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-3xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between bg-gradient-to-r from-purple-50 to-indigo-50">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-900">Consent-Details</h2>
|
||||
<p className="text-sm text-gray-500">{record.email}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-white/50 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5 text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-6">
|
||||
{/* User Info */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="bg-gray-50 rounded-xl p-4">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<User className="w-5 h-5 text-gray-400" />
|
||||
<span className="font-medium text-gray-700">Benutzerinformationen</span>
|
||||
</div>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Name:</span>
|
||||
<span className="font-medium">{record.firstName} {record.lastName}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">E-Mail:</span>
|
||||
<span className="font-medium">{record.email}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">User-ID:</span>
|
||||
<span className="font-mono text-xs bg-gray-200 px-2 py-0.5 rounded">{record.odentifier}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 rounded-xl p-4">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<Shield className="w-5 h-5 text-gray-400" />
|
||||
<span className="font-medium text-gray-700">Consent-Status</span>
|
||||
</div>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-500">Typ:</span>
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${typeColors[record.consentType]}`}>
|
||||
{typeLabels[record.consentType]}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-500">Status:</span>
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${statusColors[record.status]}`}>
|
||||
{statusLabels[record.status]}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Version:</span>
|
||||
<span className="font-mono font-medium">v{record.currentVersion}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Technical Details */}
|
||||
<div className="bg-gray-50 rounded-xl p-4">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<Monitor className="w-5 h-5 text-gray-400" />
|
||||
<span className="font-medium text-gray-700">Technische Details (letzter Consent)</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<div className="text-gray-500 mb-1">IP-Adresse</div>
|
||||
<div className="font-mono text-xs bg-white px-3 py-2 rounded border">{record.ipAddress}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500 mb-1">Quelle</div>
|
||||
<div className="bg-white px-3 py-2 rounded border">{record.source}</div>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<div className="text-gray-500 mb-1">User-Agent</div>
|
||||
<div className="font-mono text-xs bg-white px-3 py-2 rounded border break-all">{record.userAgent}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* History Timeline */}
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<History className="w-5 h-5 text-purple-600" />
|
||||
<span className="font-semibold text-gray-900">Consent-Historie</span>
|
||||
<span className="text-xs bg-purple-100 text-purple-700 px-2 py-0.5 rounded-full">
|
||||
{record.history.length} Einträge
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
{/* Timeline line */}
|
||||
<div className="absolute left-[22px] top-0 bottom-0 w-0.5 bg-gray-200" />
|
||||
|
||||
<div className="space-y-4">
|
||||
{record.history.map((entry, index) => (
|
||||
<div key={entry.id} className="relative flex gap-4">
|
||||
{/* Icon */}
|
||||
<div className="relative z-10 bg-white p-1 rounded-full">
|
||||
{actionIcons[entry.action]}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 bg-white rounded-xl border border-gray-200 p-4 shadow-sm">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{actionLabels[entry.action]}</div>
|
||||
{entry.documentTitle && (
|
||||
<div className="text-sm text-purple-600 font-medium">{entry.documentTitle}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-xs font-mono bg-gray-100 px-2 py-1 rounded">v{entry.version}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 text-xs text-gray-500 mb-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="w-3 h-3" />
|
||||
{formatDateTime(entry.timestamp)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Globe className="w-3 h-3" />
|
||||
{entry.ipAddress}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-500">
|
||||
<span className="font-medium">Quelle:</span> {entry.source}
|
||||
</div>
|
||||
|
||||
{entry.notes && (
|
||||
<div className="mt-2 text-sm text-gray-600 bg-gray-50 rounded-lg px-3 py-2 border-l-2 border-purple-300">
|
||||
{entry.notes}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Expandable User-Agent */}
|
||||
<details className="mt-2">
|
||||
<summary className="text-xs text-gray-400 cursor-pointer hover:text-gray-600">
|
||||
User-Agent anzeigen
|
||||
</summary>
|
||||
<div className="mt-1 font-mono text-xs text-gray-500 bg-gray-50 p-2 rounded break-all">
|
||||
{entry.userAgent}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer Actions */}
|
||||
<div className="px-6 py-4 border-t border-gray-200 bg-gray-50 flex items-center justify-between">
|
||||
<div className="text-xs text-gray-500">
|
||||
Consent-ID: <span className="font-mono">{record.id}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{record.status === 'granted' && !showRevokeConfirm && (
|
||||
<button
|
||||
onClick={() => setShowRevokeConfirm(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||
>
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
Widerrufen
|
||||
</button>
|
||||
)}
|
||||
|
||||
{showRevokeConfirm && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-red-600">Wirklich widerrufen?</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
onRevoke(record.id)
|
||||
onClose()
|
||||
}}
|
||||
className="px-3 py-1.5 bg-red-600 text-white text-sm rounded-lg hover:bg-red-700"
|
||||
>
|
||||
Ja, widerrufen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowRevokeConfirm(false)}
|
||||
className="px-3 py-1.5 bg-gray-200 text-gray-700 text-sm rounded-lg hover:bg-gray-300"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// TABLE ROW COMPONENT
|
||||
// =============================================================================
|
||||
|
||||
interface ConsentRecordRowProps {
|
||||
record: ConsentRecord
|
||||
onShowDetails: (record: ConsentRecord) => void
|
||||
}
|
||||
|
||||
function ConsentRecordRow({ record, onShowDetails }: ConsentRecordRowProps) {
|
||||
return (
|
||||
<tr className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4">
|
||||
<div className="text-sm font-medium text-gray-900">{record.email}</div>
|
||||
<div className="text-xs text-gray-500">{record.odentifier}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${typeColors[record.consentType]}`}>
|
||||
{typeLabels[record.consentType]}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${statusColors[record.status]}`}>
|
||||
{statusLabels[record.status]}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500">
|
||||
{formatDate(record.grantedAt)}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500">
|
||||
{formatDate(record.withdrawnAt)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className="font-mono text-xs bg-gray-100 px-2 py-0.5 rounded">v{record.currentVersion}</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500">
|
||||
<div className="flex items-center gap-1">
|
||||
<History className="w-3 h-3" />
|
||||
{record.history.length}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<button
|
||||
onClick={() => onShowDetails(record)}
|
||||
className="text-sm text-purple-600 hover:text-purple-700 font-medium"
|
||||
>
|
||||
Details
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN PAGE
|
||||
// =============================================================================
|
||||
|
||||
export default function EinwilligungenPage() {
|
||||
const { state } = useSDK()
|
||||
const [records, setRecords] = useState<ConsentRecord[]>(mockRecords)
|
||||
const [filter, setFilter] = useState<string>('all')
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [selectedRecord, setSelectedRecord] = useState<ConsentRecord | null>(null)
|
||||
|
||||
const filteredRecords = records.filter(record => {
|
||||
const matchesFilter = filter === 'all' || record.consentType === filter || record.status === filter
|
||||
const matchesSearch = searchQuery === '' ||
|
||||
record.email.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
record.odentifier.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
return matchesFilter && matchesSearch
|
||||
})
|
||||
|
||||
const grantedCount = records.filter(r => r.status === 'granted').length
|
||||
const withdrawnCount = records.filter(r => r.status === 'withdrawn').length
|
||||
const versionUpdates = records.reduce((acc, r) => acc + r.history.filter(h => h.action === 'version_update').length, 0)
|
||||
|
||||
const handleRevoke = (recordId: string) => {
|
||||
setRecords(prev => prev.map(r => {
|
||||
if (r.id === recordId) {
|
||||
const now = new Date()
|
||||
return {
|
||||
...r,
|
||||
status: 'withdrawn' as ConsentStatus,
|
||||
withdrawnAt: now,
|
||||
history: [
|
||||
...r.history,
|
||||
{
|
||||
id: `h-${recordId}-${r.history.length + 1}`,
|
||||
action: 'withdrawn' as HistoryAction,
|
||||
timestamp: now,
|
||||
version: r.currentVersion,
|
||||
ipAddress: 'Admin-Portal',
|
||||
userAgent: 'Admin Action',
|
||||
source: 'Manueller Widerruf durch Admin',
|
||||
notes: 'Widerruf über Admin-Portal durchgeführt',
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
return r
|
||||
}))
|
||||
}
|
||||
|
||||
const stepInfo = STEP_EXPLANATIONS['einwilligungen']
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Step Header */}
|
||||
<StepHeader
|
||||
stepId="einwilligungen"
|
||||
title={stepInfo.title}
|
||||
description={stepInfo.description}
|
||||
explanation={stepInfo.explanation}
|
||||
tips={stepInfo.tips}
|
||||
>
|
||||
<button 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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
Export
|
||||
</button>
|
||||
</StepHeader>
|
||||
|
||||
{/* Navigation Tabs */}
|
||||
<EinwilligungenNavTabs />
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="text-sm text-gray-500">Gesamt</div>
|
||||
<div className="text-3xl font-bold text-gray-900">{records.length}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-green-200 p-6">
|
||||
<div className="text-sm text-green-600">Aktive Einwilligungen</div>
|
||||
<div className="text-3xl font-bold text-green-600">{grantedCount}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-red-200 p-6">
|
||||
<div className="text-sm text-red-600">Widerrufen</div>
|
||||
<div className="text-3xl font-bold text-red-600">{withdrawnCount}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-blue-200 p-6">
|
||||
<div className="text-sm text-blue-600">Versions-Updates</div>
|
||||
<div className="text-3xl font-bold text-blue-600">{versionUpdates}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Banner */}
|
||||
<div className="bg-gradient-to-r from-purple-50 to-indigo-50 rounded-xl border border-purple-200 p-4 flex items-start gap-3">
|
||||
<History className="w-5 h-5 text-purple-600 mt-0.5" />
|
||||
<div>
|
||||
<div className="font-medium text-purple-900">Consent-Historie aktiviert</div>
|
||||
<div className="text-sm text-purple-700">
|
||||
Alle Änderungen an Einwilligungen werden protokolliert, inkl. Zustimmungen zu neuen Versionen von AGB, DSI und anderen Dokumenten.
|
||||
Klicken Sie auf "Details" um die vollständige Historie eines Nutzers einzusehen.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search and Filter */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1 relative">
|
||||
<svg className="w-5 h-5 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" 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>
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="E-Mail oder User-ID suchen..."
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{['all', 'granted', 'withdrawn', 'terms', 'privacy', 'cookies', 'marketing', 'analytics'].map(f => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
className={`px-3 py-1 text-sm rounded-full transition-colors ${
|
||||
filter === f
|
||||
? 'bg-purple-600 text-white'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{f === 'all' ? 'Alle' :
|
||||
f === 'granted' ? 'Erteilt' :
|
||||
f === 'withdrawn' ? 'Widerrufen' :
|
||||
f === 'terms' ? 'AGB' :
|
||||
f === 'privacy' ? 'DSI' :
|
||||
f === 'cookies' ? 'Cookies' :
|
||||
f === 'marketing' ? 'Marketing' : 'Analyse'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Records Table */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Nutzer</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Typ</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Erteilt am</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Widerrufen am</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Version</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Historie</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{filteredRecords.map(record => (
|
||||
<ConsentRecordRow
|
||||
key={record.id}
|
||||
record={record}
|
||||
onShowDetails={setSelectedRecord}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{filteredRecords.length === 0 && (
|
||||
<div className="p-12 text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Keine Einträge gefunden</h3>
|
||||
<p className="mt-2 text-gray-500">Passen Sie die Suche oder den Filter an.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination placeholder */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-gray-500">
|
||||
Zeige {filteredRecords.length} von {records.length} Einträgen
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="px-3 py-1 text-sm text-gray-600 bg-gray-100 rounded-lg hover:bg-gray-200">
|
||||
Zurück
|
||||
</button>
|
||||
<button className="px-3 py-1 text-sm text-white bg-purple-600 rounded-lg">1</button>
|
||||
<button className="px-3 py-1 text-sm text-gray-600 bg-gray-100 rounded-lg hover:bg-gray-200">2</button>
|
||||
<button className="px-3 py-1 text-sm text-gray-600 bg-gray-100 rounded-lg hover:bg-gray-200">3</button>
|
||||
<button className="px-3 py-1 text-sm text-gray-600 bg-gray-100 rounded-lg hover:bg-gray-200">
|
||||
Weiter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Detail Modal */}
|
||||
{selectedRecord && (
|
||||
<ConsentDetailModal
|
||||
record={selectedRecord}
|
||||
onClose={() => setSelectedRecord(null)}
|
||||
onRevoke={handleRevoke}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,401 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { useSDK } from '@/lib/sdk'
|
||||
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
interface Escalation {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
type: 'data-breach' | 'dsr-overdue' | 'audit-finding' | 'compliance-gap' | 'security-incident'
|
||||
severity: 'critical' | 'high' | 'medium' | 'low'
|
||||
status: 'open' | 'in-progress' | 'resolved' | 'escalated'
|
||||
createdAt: Date
|
||||
deadline: Date | null
|
||||
assignedTo: string
|
||||
escalatedTo: string | null
|
||||
relatedItems: string[]
|
||||
actions: EscalationAction[]
|
||||
}
|
||||
|
||||
interface EscalationAction {
|
||||
id: string
|
||||
action: string
|
||||
performedBy: string
|
||||
performedAt: Date
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MOCK DATA
|
||||
// =============================================================================
|
||||
|
||||
const mockEscalations: Escalation[] = [
|
||||
{
|
||||
id: 'esc-001',
|
||||
title: 'Potenzielle Datenpanne - Kundendaten',
|
||||
description: 'Unberechtigter Zugriff auf Kundendatenbank festgestellt',
|
||||
type: 'data-breach',
|
||||
severity: 'critical',
|
||||
status: 'escalated',
|
||||
createdAt: new Date('2024-01-22'),
|
||||
deadline: new Date('2024-01-25'),
|
||||
assignedTo: 'IT Security',
|
||||
escalatedTo: 'CISO',
|
||||
relatedItems: ['INC-2024-001'],
|
||||
actions: [
|
||||
{ id: 'a1', action: 'Incident erkannt und gemeldet', performedBy: 'SOC Team', performedAt: new Date('2024-01-22T08:00:00') },
|
||||
{ id: 'a2', action: 'An CISO eskaliert', performedBy: 'IT Security', performedAt: new Date('2024-01-22T09:30:00') },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'esc-002',
|
||||
title: 'DSR-Anfrage ueberfaellig',
|
||||
description: 'Auskunftsanfrage von Max Mustermann ueberschreitet 30-Tage-Frist',
|
||||
type: 'dsr-overdue',
|
||||
severity: 'high',
|
||||
status: 'in-progress',
|
||||
createdAt: new Date('2024-01-20'),
|
||||
deadline: new Date('2024-01-23'),
|
||||
assignedTo: 'DSB Mueller',
|
||||
escalatedTo: null,
|
||||
relatedItems: ['DSR-001'],
|
||||
actions: [
|
||||
{ id: 'a1', action: 'Automatische Eskalation bei Fristueberschreitung', performedBy: 'System', performedAt: new Date('2024-01-20') },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'esc-003',
|
||||
title: 'Kritische Audit-Feststellung',
|
||||
description: 'Fehlende Auftragsverarbeitungsvertraege mit Cloud-Providern',
|
||||
type: 'audit-finding',
|
||||
severity: 'high',
|
||||
status: 'in-progress',
|
||||
createdAt: new Date('2024-01-15'),
|
||||
deadline: new Date('2024-02-15'),
|
||||
assignedTo: 'Rechtsabteilung',
|
||||
escalatedTo: null,
|
||||
relatedItems: ['AUDIT-2024-Q1-003'],
|
||||
actions: [
|
||||
{ id: 'a1', action: 'Feststellung dokumentiert', performedBy: 'Auditor', performedAt: new Date('2024-01-15') },
|
||||
{ id: 'a2', action: 'An Rechtsabteilung zugewiesen', performedBy: 'DSB Mueller', performedAt: new Date('2024-01-16') },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'esc-004',
|
||||
title: 'AI Act Compliance-Luecke',
|
||||
description: 'Hochrisiko-KI-System ohne Risikomanagementsystem',
|
||||
type: 'compliance-gap',
|
||||
severity: 'high',
|
||||
status: 'open',
|
||||
createdAt: new Date('2024-01-18'),
|
||||
deadline: new Date('2024-03-01'),
|
||||
assignedTo: 'KI-Compliance Team',
|
||||
escalatedTo: null,
|
||||
relatedItems: ['AI-SYS-002'],
|
||||
actions: [],
|
||||
},
|
||||
{
|
||||
id: 'esc-005',
|
||||
title: 'Sicherheitsluecke in Anwendung',
|
||||
description: 'Kritische CVE in verwendeter Bibliothek entdeckt',
|
||||
type: 'security-incident',
|
||||
severity: 'medium',
|
||||
status: 'resolved',
|
||||
createdAt: new Date('2024-01-10'),
|
||||
deadline: new Date('2024-01-17'),
|
||||
assignedTo: 'Entwicklung',
|
||||
escalatedTo: null,
|
||||
relatedItems: ['CVE-2024-12345'],
|
||||
actions: [
|
||||
{ id: 'a1', action: 'CVE identifiziert', performedBy: 'Security Scanner', performedAt: new Date('2024-01-10') },
|
||||
{ id: 'a2', action: 'Patch entwickelt', performedBy: 'Entwicklung', performedAt: new Date('2024-01-12') },
|
||||
{ id: 'a3', action: 'Patch deployed', performedBy: 'DevOps', performedAt: new Date('2024-01-13') },
|
||||
{ id: 'a4', action: 'Eskalation geschlossen', performedBy: 'IT Security', performedAt: new Date('2024-01-14') },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// =============================================================================
|
||||
// COMPONENTS
|
||||
// =============================================================================
|
||||
|
||||
function EscalationCard({ escalation }: { escalation: Escalation }) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
const typeLabels = {
|
||||
'data-breach': 'Datenpanne',
|
||||
'dsr-overdue': 'DSR ueberfaellig',
|
||||
'audit-finding': 'Audit-Feststellung',
|
||||
'compliance-gap': 'Compliance-Luecke',
|
||||
'security-incident': 'Sicherheitsvorfall',
|
||||
}
|
||||
|
||||
const typeColors = {
|
||||
'data-breach': 'bg-red-100 text-red-700',
|
||||
'dsr-overdue': 'bg-orange-100 text-orange-700',
|
||||
'audit-finding': 'bg-yellow-100 text-yellow-700',
|
||||
'compliance-gap': 'bg-purple-100 text-purple-700',
|
||||
'security-incident': 'bg-blue-100 text-blue-700',
|
||||
}
|
||||
|
||||
const severityColors = {
|
||||
critical: 'bg-red-500 text-white',
|
||||
high: 'bg-orange-500 text-white',
|
||||
medium: 'bg-yellow-500 text-white',
|
||||
low: 'bg-green-500 text-white',
|
||||
}
|
||||
|
||||
const statusColors = {
|
||||
open: 'bg-blue-100 text-blue-700',
|
||||
'in-progress': 'bg-yellow-100 text-yellow-700',
|
||||
resolved: 'bg-green-100 text-green-700',
|
||||
escalated: 'bg-red-100 text-red-700',
|
||||
}
|
||||
|
||||
const statusLabels = {
|
||||
open: 'Offen',
|
||||
'in-progress': 'In Bearbeitung',
|
||||
resolved: 'Geloest',
|
||||
escalated: 'Eskaliert',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-xl border-2 p-6 ${
|
||||
escalation.severity === 'critical' ? 'border-red-300' :
|
||||
escalation.severity === 'high' ? 'border-orange-300' :
|
||||
escalation.status === 'resolved' ? 'border-green-200' : 'border-gray-200'
|
||||
}`}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${severityColors[escalation.severity]}`}>
|
||||
{escalation.severity.toUpperCase()}
|
||||
</span>
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${typeColors[escalation.type]}`}>
|
||||
{typeLabels[escalation.type]}
|
||||
</span>
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${statusColors[escalation.status]}`}>
|
||||
{statusLabels[escalation.status]}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">{escalation.title}</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">{escalation.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500">Zugewiesen: </span>
|
||||
<span className="font-medium text-gray-700">{escalation.assignedTo}</span>
|
||||
</div>
|
||||
{escalation.escalatedTo && (
|
||||
<div>
|
||||
<span className="text-gray-500">Eskaliert an: </span>
|
||||
<span className="font-medium text-red-600">{escalation.escalatedTo}</span>
|
||||
</div>
|
||||
)}
|
||||
{escalation.deadline && (
|
||||
<div>
|
||||
<span className="text-gray-500">Frist: </span>
|
||||
<span className="font-medium text-gray-700">{escalation.deadline.toLocaleDateString('de-DE')}</span>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="text-gray-500">Erstellt: </span>
|
||||
<span className="font-medium text-gray-700">{escalation.createdAt.toLocaleDateString('de-DE')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{escalation.relatedItems.length > 0 && (
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<span className="text-sm text-gray-500">Verknuepft:</span>
|
||||
{escalation.relatedItems.map(item => (
|
||||
<span key={item} className="px-2 py-0.5 text-xs bg-gray-100 text-gray-600 rounded font-mono">
|
||||
{item}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{escalation.actions.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="text-sm text-purple-600 hover:text-purple-700 flex items-center gap-1"
|
||||
>
|
||||
<span>{expanded ? 'Verlauf ausblenden' : `Verlauf anzeigen (${escalation.actions.length})`}</span>
|
||||
<svg className={`w-4 h-4 transition-transform ${expanded ? '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>
|
||||
{expanded && (
|
||||
<div className="mt-3 space-y-2">
|
||||
{escalation.actions.map(action => (
|
||||
<div key={action.id} className="flex items-start gap-3 text-sm p-2 bg-gray-50 rounded-lg">
|
||||
<div className="w-2 h-2 bg-purple-500 rounded-full mt-1.5" />
|
||||
<div className="flex-1">
|
||||
<p className="text-gray-700">{action.action}</p>
|
||||
<p className="text-gray-500 text-xs">
|
||||
{action.performedBy} - {action.performedAt.toLocaleString('de-DE')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between">
|
||||
<span className="text-xs text-gray-500">{escalation.id}</span>
|
||||
{escalation.status !== 'resolved' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="px-3 py-1 text-sm text-purple-600 hover:bg-purple-50 rounded-lg transition-colors">
|
||||
Aktion hinzufuegen
|
||||
</button>
|
||||
{escalation.status !== 'escalated' && (
|
||||
<button className="px-3 py-1 text-sm text-orange-600 hover:bg-orange-50 rounded-lg transition-colors">
|
||||
Eskalieren
|
||||
</button>
|
||||
)}
|
||||
<button className="px-3 py-1 text-sm bg-green-50 text-green-700 hover:bg-green-100 rounded-lg transition-colors">
|
||||
Loesen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN PAGE
|
||||
// =============================================================================
|
||||
|
||||
export default function EscalationsPage() {
|
||||
const { state } = useSDK()
|
||||
const [escalations] = useState<Escalation[]>(mockEscalations)
|
||||
const [filter, setFilter] = useState<string>('all')
|
||||
|
||||
const filteredEscalations = filter === 'all'
|
||||
? escalations
|
||||
: escalations.filter(e => e.type === filter || e.status === filter || e.severity === filter)
|
||||
|
||||
const openCount = escalations.filter(e => e.status === 'open').length
|
||||
const criticalCount = escalations.filter(e => e.severity === 'critical' && e.status !== 'resolved').length
|
||||
const escalatedCount = escalations.filter(e => e.status === 'escalated').length
|
||||
|
||||
const stepInfo = STEP_EXPLANATIONS['escalations']
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Step Header */}
|
||||
<StepHeader
|
||||
stepId="escalations"
|
||||
title={stepInfo.title}
|
||||
description={stepInfo.description}
|
||||
explanation={stepInfo.explanation}
|
||||
tips={stepInfo.tips}
|
||||
>
|
||||
<button 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>
|
||||
Eskalation erstellen
|
||||
</button>
|
||||
</StepHeader>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="text-sm text-gray-500">Gesamt aktiv</div>
|
||||
<div className="text-3xl font-bold text-gray-900">
|
||||
{escalations.filter(e => e.status !== 'resolved').length}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-red-200 p-6">
|
||||
<div className="text-sm text-red-600">Kritisch</div>
|
||||
<div className="text-3xl font-bold text-red-600">{criticalCount}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-orange-200 p-6">
|
||||
<div className="text-sm text-orange-600">Eskaliert</div>
|
||||
<div className="text-3xl font-bold text-orange-600">{escalatedCount}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-blue-200 p-6">
|
||||
<div className="text-sm text-blue-600">Offen</div>
|
||||
<div className="text-3xl font-bold text-blue-600">{openCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Critical Alert */}
|
||||
{criticalCount > 0 && (
|
||||
<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">
|
||||
<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>
|
||||
<h4 className="font-medium text-red-800">{criticalCount} kritische Eskalation(en) erfordern sofortige Aufmerksamkeit</h4>
|
||||
<p className="text-sm text-red-600">Priorisieren Sie diese Vorfaelle zur Vermeidung von Schaeden.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filter */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm text-gray-500">Filter:</span>
|
||||
{['all', 'open', 'escalated', 'critical', 'data-breach', 'compliance-gap'].map(f => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
className={`px-3 py-1 text-sm rounded-full transition-colors ${
|
||||
filter === f
|
||||
? 'bg-purple-600 text-white'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{f === 'all' ? 'Alle' :
|
||||
f === 'open' ? 'Offen' :
|
||||
f === 'escalated' ? 'Eskaliert' :
|
||||
f === 'critical' ? 'Kritisch' :
|
||||
f === 'data-breach' ? 'Datenpannen' : 'Compliance-Luecken'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Escalations List */}
|
||||
<div className="space-y-4">
|
||||
{filteredEscalations
|
||||
.sort((a, b) => {
|
||||
// Sort by severity and status
|
||||
const severityOrder = { critical: 0, high: 1, medium: 2, low: 3 }
|
||||
const statusOrder = { escalated: 0, open: 1, 'in-progress': 2, resolved: 3 }
|
||||
const severityDiff = severityOrder[a.severity] - severityOrder[b.severity]
|
||||
if (severityDiff !== 0) return severityDiff
|
||||
return statusOrder[a.status] - statusOrder[b.status]
|
||||
})
|
||||
.map(escalation => (
|
||||
<EscalationCard key={escalation.id} escalation={escalation} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredEscalations.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-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Keine Eskalationen gefunden</h3>
|
||||
<p className="mt-2 text-gray-500">Passen Sie den Filter an.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,470 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useSDK, Evidence as SDKEvidence, EvidenceType } from '@/lib/sdk'
|
||||
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
type DisplayEvidenceType = 'document' | 'screenshot' | 'log' | 'audit-report' | 'certificate'
|
||||
type DisplayFormat = 'pdf' | 'image' | 'text' | 'json'
|
||||
type DisplayStatus = 'valid' | 'expired' | 'pending-review'
|
||||
|
||||
interface DisplayEvidence {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
displayType: DisplayEvidenceType
|
||||
format: DisplayFormat
|
||||
controlId: string
|
||||
linkedRequirements: string[]
|
||||
linkedControls: string[]
|
||||
uploadedBy: string
|
||||
uploadedAt: Date
|
||||
validFrom: Date
|
||||
validUntil: Date | null
|
||||
status: DisplayStatus
|
||||
fileSize: string
|
||||
fileUrl: string | null
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// =============================================================================
|
||||
|
||||
function mapEvidenceTypeToDisplay(type: EvidenceType): DisplayEvidenceType {
|
||||
switch (type) {
|
||||
case 'DOCUMENT': return 'document'
|
||||
case 'SCREENSHOT': return 'screenshot'
|
||||
case 'LOG': return 'log'
|
||||
case 'CERTIFICATE': return 'certificate'
|
||||
case 'AUDIT_REPORT': return 'audit-report'
|
||||
default: return 'document'
|
||||
}
|
||||
}
|
||||
|
||||
function mapDisplayTypeToEvidence(type: DisplayEvidenceType): EvidenceType {
|
||||
switch (type) {
|
||||
case 'document': return 'DOCUMENT'
|
||||
case 'screenshot': return 'SCREENSHOT'
|
||||
case 'log': return 'LOG'
|
||||
case 'certificate': return 'CERTIFICATE'
|
||||
case 'audit-report': return 'AUDIT_REPORT'
|
||||
default: return 'DOCUMENT'
|
||||
}
|
||||
}
|
||||
|
||||
function getEvidenceStatus(validUntil: Date | null): DisplayStatus {
|
||||
if (!validUntil) return 'pending-review'
|
||||
const now = new Date()
|
||||
if (validUntil < now) return 'expired'
|
||||
return 'valid'
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// EVIDENCE TEMPLATES
|
||||
// =============================================================================
|
||||
|
||||
interface EvidenceTemplate {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
type: EvidenceType
|
||||
displayType: DisplayEvidenceType
|
||||
format: DisplayFormat
|
||||
controlId: string
|
||||
linkedRequirements: string[]
|
||||
linkedControls: string[]
|
||||
uploadedBy: string
|
||||
validityDays: number
|
||||
fileSize: string
|
||||
}
|
||||
|
||||
const evidenceTemplates: EvidenceTemplate[] = [
|
||||
{
|
||||
id: 'ev-dse-001',
|
||||
name: 'Datenschutzerklaerung v2.3',
|
||||
description: 'Aktuelle Datenschutzerklaerung fuer Website und App',
|
||||
type: 'DOCUMENT',
|
||||
displayType: 'document',
|
||||
format: 'pdf',
|
||||
controlId: 'ctrl-org-001',
|
||||
linkedRequirements: ['req-gdpr-13', 'req-gdpr-14'],
|
||||
linkedControls: ['ctrl-org-001'],
|
||||
uploadedBy: 'DSB',
|
||||
validityDays: 365,
|
||||
fileSize: '245 KB',
|
||||
},
|
||||
{
|
||||
id: 'ev-pentest-001',
|
||||
name: 'Penetrationstest Report Q4/2024',
|
||||
description: 'Externer Penetrationstest durch Security-Partner',
|
||||
type: 'AUDIT_REPORT',
|
||||
displayType: 'audit-report',
|
||||
format: 'pdf',
|
||||
controlId: 'ctrl-tom-001',
|
||||
linkedRequirements: ['req-gdpr-32', 'req-iso-a12'],
|
||||
linkedControls: ['ctrl-tom-001', 'ctrl-tom-002', 'ctrl-det-001'],
|
||||
uploadedBy: 'IT Security Team',
|
||||
validityDays: 365,
|
||||
fileSize: '2.1 MB',
|
||||
},
|
||||
{
|
||||
id: 'ev-iso-cert',
|
||||
name: 'ISO 27001 Zertifikat',
|
||||
description: 'Zertifizierung des ISMS',
|
||||
type: 'CERTIFICATE',
|
||||
displayType: 'certificate',
|
||||
format: 'pdf',
|
||||
controlId: 'ctrl-tom-001',
|
||||
linkedRequirements: ['req-iso-4.1', 'req-iso-5.1'],
|
||||
linkedControls: [],
|
||||
uploadedBy: 'QM Abteilung',
|
||||
validityDays: 365,
|
||||
fileSize: '156 KB',
|
||||
},
|
||||
{
|
||||
id: 'ev-schulung-001',
|
||||
name: 'Schulungsnachweis Datenschutz 2024',
|
||||
description: 'Teilnehmerliste und Schulungsinhalt',
|
||||
type: 'DOCUMENT',
|
||||
displayType: 'document',
|
||||
format: 'pdf',
|
||||
controlId: 'ctrl-org-001',
|
||||
linkedRequirements: ['req-gdpr-39'],
|
||||
linkedControls: ['ctrl-org-001'],
|
||||
uploadedBy: 'HR Team',
|
||||
validityDays: 365,
|
||||
fileSize: '890 KB',
|
||||
},
|
||||
{
|
||||
id: 'ev-rbac-001',
|
||||
name: 'Access Control Screenshot',
|
||||
description: 'Nachweis der RBAC-Konfiguration',
|
||||
type: 'SCREENSHOT',
|
||||
displayType: 'screenshot',
|
||||
format: 'image',
|
||||
controlId: 'ctrl-tom-001',
|
||||
linkedRequirements: ['req-gdpr-32'],
|
||||
linkedControls: ['ctrl-tom-001'],
|
||||
uploadedBy: 'Admin',
|
||||
validityDays: 0,
|
||||
fileSize: '1.2 MB',
|
||||
},
|
||||
{
|
||||
id: 'ev-log-001',
|
||||
name: 'Audit Log Export',
|
||||
description: 'Monatlicher Audit-Log Export',
|
||||
type: 'LOG',
|
||||
displayType: 'log',
|
||||
format: 'json',
|
||||
controlId: 'ctrl-det-001',
|
||||
linkedRequirements: ['req-gdpr-32'],
|
||||
linkedControls: ['ctrl-det-001'],
|
||||
uploadedBy: 'System',
|
||||
validityDays: 90,
|
||||
fileSize: '4.5 MB',
|
||||
},
|
||||
]
|
||||
|
||||
// =============================================================================
|
||||
// COMPONENTS
|
||||
// =============================================================================
|
||||
|
||||
function EvidenceCard({ evidence, onDelete }: { evidence: DisplayEvidence; onDelete: () => void }) {
|
||||
const typeIcons = {
|
||||
document: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
),
|
||||
screenshot: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
),
|
||||
log: (
|
||||
<svg className="w-6 h-6" 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-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
|
||||
</svg>
|
||||
),
|
||||
'audit-report': (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
),
|
||||
certificate: (
|
||||
<svg className="w-6 h-6" 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>
|
||||
),
|
||||
}
|
||||
|
||||
const statusColors = {
|
||||
valid: 'bg-green-100 text-green-700 border-green-200',
|
||||
expired: 'bg-red-100 text-red-700 border-red-200',
|
||||
'pending-review': 'bg-yellow-100 text-yellow-700 border-yellow-200',
|
||||
}
|
||||
|
||||
const statusLabels = {
|
||||
valid: 'Gueltig',
|
||||
expired: 'Abgelaufen',
|
||||
'pending-review': 'Pruefung ausstehend',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-xl border-2 p-6 ${
|
||||
evidence.status === 'expired' ? 'border-red-200' :
|
||||
evidence.status === 'pending-review' ? 'border-yellow-200' : 'border-gray-200'
|
||||
}`}>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className={`w-12 h-12 rounded-lg flex items-center justify-center ${
|
||||
evidence.displayType === 'certificate' ? 'bg-yellow-100 text-yellow-600' :
|
||||
evidence.displayType === 'audit-report' ? 'bg-purple-100 text-purple-600' :
|
||||
evidence.displayType === 'screenshot' ? 'bg-blue-100 text-blue-600' :
|
||||
evidence.displayType === 'log' ? 'bg-green-100 text-green-600' :
|
||||
'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{typeIcons[evidence.displayType]}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-gray-900">{evidence.name}</h3>
|
||||
<span className={`px-3 py-1 text-xs rounded-full ${statusColors[evidence.status]}`}>
|
||||
{statusLabels[evidence.status]}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-1">{evidence.description}</p>
|
||||
|
||||
<div className="mt-3 flex items-center gap-4 text-sm text-gray-500">
|
||||
<span>Hochgeladen: {evidence.uploadedAt.toLocaleDateString('de-DE')}</span>
|
||||
{evidence.validUntil && (
|
||||
<span className={evidence.status === 'expired' ? 'text-red-600' : ''}>
|
||||
Gueltig bis: {evidence.validUntil.toLocaleDateString('de-DE')}
|
||||
</span>
|
||||
)}
|
||||
<span>{evidence.fileSize}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center gap-2 flex-wrap">
|
||||
{evidence.linkedRequirements.map(req => (
|
||||
<span key={req} className="px-2 py-0.5 text-xs bg-blue-50 text-blue-600 rounded">
|
||||
{req}
|
||||
</span>
|
||||
))}
|
||||
{evidence.linkedControls.map(ctrl => (
|
||||
<span key={ctrl} className="px-2 py-0.5 text-xs bg-green-50 text-green-600 rounded">
|
||||
{ctrl}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between">
|
||||
<span className="text-sm text-gray-500">Hochgeladen von: {evidence.uploadedBy}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="px-3 py-1 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
Anzeigen
|
||||
</button>
|
||||
<button className="px-3 py-1 text-sm text-purple-600 hover:bg-purple-50 rounded-lg transition-colors">
|
||||
Herunterladen
|
||||
</button>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="px-3 py-1 text-sm text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||
>
|
||||
Loeschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN PAGE
|
||||
// =============================================================================
|
||||
|
||||
export default function EvidencePage() {
|
||||
const { state, dispatch } = useSDK()
|
||||
const [filter, setFilter] = useState<string>('all')
|
||||
|
||||
// Load evidence based on controls when controls exist
|
||||
useEffect(() => {
|
||||
if (state.controls.length > 0 && state.evidence.length === 0) {
|
||||
// Add relevant evidence based on controls
|
||||
const relevantEvidence = evidenceTemplates.filter(e =>
|
||||
state.controls.some(c => c.id === e.controlId || e.linkedControls.includes(c.id))
|
||||
)
|
||||
|
||||
const now = new Date()
|
||||
relevantEvidence.forEach(template => {
|
||||
const validFrom = new Date(now)
|
||||
validFrom.setMonth(validFrom.getMonth() - 1) // Uploaded 1 month ago
|
||||
|
||||
const validUntil = template.validityDays > 0
|
||||
? new Date(validFrom.getTime() + template.validityDays * 24 * 60 * 60 * 1000)
|
||||
: null
|
||||
|
||||
const sdkEvidence: SDKEvidence = {
|
||||
id: template.id,
|
||||
controlId: template.controlId,
|
||||
type: template.type,
|
||||
name: template.name,
|
||||
description: template.description,
|
||||
fileUrl: null,
|
||||
validFrom,
|
||||
validUntil,
|
||||
uploadedBy: template.uploadedBy,
|
||||
uploadedAt: validFrom,
|
||||
}
|
||||
dispatch({ type: 'ADD_EVIDENCE', payload: sdkEvidence })
|
||||
})
|
||||
}
|
||||
}, [state.controls, state.evidence.length, dispatch])
|
||||
|
||||
// Convert SDK evidence to display evidence
|
||||
const displayEvidence: DisplayEvidence[] = state.evidence.map(ev => {
|
||||
const template = evidenceTemplates.find(t => t.id === ev.id)
|
||||
|
||||
return {
|
||||
id: ev.id,
|
||||
name: ev.name,
|
||||
description: ev.description,
|
||||
displayType: mapEvidenceTypeToDisplay(ev.type),
|
||||
format: template?.format || 'pdf',
|
||||
controlId: ev.controlId,
|
||||
linkedRequirements: template?.linkedRequirements || [],
|
||||
linkedControls: template?.linkedControls || [ev.controlId],
|
||||
uploadedBy: ev.uploadedBy,
|
||||
uploadedAt: ev.uploadedAt,
|
||||
validFrom: ev.validFrom,
|
||||
validUntil: ev.validUntil,
|
||||
status: getEvidenceStatus(ev.validUntil),
|
||||
fileSize: template?.fileSize || 'Unbekannt',
|
||||
fileUrl: ev.fileUrl,
|
||||
}
|
||||
})
|
||||
|
||||
const filteredEvidence = filter === 'all'
|
||||
? displayEvidence
|
||||
: displayEvidence.filter(e => e.status === filter || e.displayType === filter)
|
||||
|
||||
const validCount = displayEvidence.filter(e => e.status === 'valid').length
|
||||
const expiredCount = displayEvidence.filter(e => e.status === 'expired').length
|
||||
const pendingCount = displayEvidence.filter(e => e.status === 'pending-review').length
|
||||
|
||||
const handleDelete = (evidenceId: string) => {
|
||||
if (confirm('Moechten Sie diesen Nachweis wirklich loeschen?')) {
|
||||
dispatch({ type: 'DELETE_EVIDENCE', payload: evidenceId })
|
||||
}
|
||||
}
|
||||
|
||||
const stepInfo = STEP_EXPLANATIONS['evidence']
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Step Header */}
|
||||
<StepHeader
|
||||
stepId="evidence"
|
||||
title={stepInfo.title}
|
||||
description={stepInfo.description}
|
||||
explanation={stepInfo.explanation}
|
||||
tips={stepInfo.tips}
|
||||
>
|
||||
<button 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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
||||
</svg>
|
||||
Nachweis hochladen
|
||||
</button>
|
||||
</StepHeader>
|
||||
|
||||
{/* Controls Alert */}
|
||||
{state.controls.length === 0 && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<svg className="w-5 h-5 text-amber-600 mt-0.5" 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>
|
||||
<h4 className="font-medium text-amber-800">Keine Kontrollen definiert</h4>
|
||||
<p className="text-sm text-amber-700 mt-1">
|
||||
Bitte definieren Sie zuerst Kontrollen, um die zugehoerigen Nachweise zu laden.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="text-sm text-gray-500">Gesamt</div>
|
||||
<div className="text-3xl font-bold text-gray-900">{displayEvidence.length}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-green-200 p-6">
|
||||
<div className="text-sm text-green-600">Gueltig</div>
|
||||
<div className="text-3xl font-bold text-green-600">{validCount}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-red-200 p-6">
|
||||
<div className="text-sm text-red-600">Abgelaufen</div>
|
||||
<div className="text-3xl font-bold text-red-600">{expiredCount}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-yellow-200 p-6">
|
||||
<div className="text-sm text-yellow-600">Pruefung ausstehend</div>
|
||||
<div className="text-3xl font-bold text-yellow-600">{pendingCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm text-gray-500">Filter:</span>
|
||||
{['all', 'valid', 'expired', 'pending-review', 'document', 'certificate', 'audit-report'].map(f => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
className={`px-3 py-1 text-sm rounded-full transition-colors ${
|
||||
filter === f
|
||||
? 'bg-purple-600 text-white'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{f === 'all' ? 'Alle' :
|
||||
f === 'valid' ? 'Gueltig' :
|
||||
f === 'expired' ? 'Abgelaufen' :
|
||||
f === 'pending-review' ? 'Ausstehend' :
|
||||
f === 'document' ? 'Dokumente' :
|
||||
f === 'certificate' ? 'Zertifikate' : 'Audit-Berichte'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Evidence List */}
|
||||
<div className="space-y-4">
|
||||
{filteredEvidence.map(ev => (
|
||||
<EvidenceCard
|
||||
key={ev.id}
|
||||
evidence={ev}
|
||||
onDelete={() => handleDelete(ev.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredEvidence.length === 0 && state.controls.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 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Keine Nachweise gefunden</h3>
|
||||
<p className="mt-2 text-gray-500">Passen Sie den Filter an oder laden Sie neue Nachweise hoch.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,693 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import {
|
||||
GCIResult,
|
||||
GCIBreakdown,
|
||||
GCIHistoryResponse,
|
||||
GCIMatrixResponse,
|
||||
NIS2Score,
|
||||
ISOGapAnalysis,
|
||||
WeightProfile,
|
||||
MaturityLevel,
|
||||
MATURITY_INFO,
|
||||
getScoreColor,
|
||||
getScoreRingColor,
|
||||
} from '@/lib/sdk/gci/types'
|
||||
import {
|
||||
getGCIScore,
|
||||
getGCIBreakdown,
|
||||
getGCIHistory,
|
||||
getGCIMatrix,
|
||||
getNIS2Score,
|
||||
getISOGapAnalysis,
|
||||
getWeightProfiles,
|
||||
} from '@/lib/sdk/gci/api'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
type TabId = 'overview' | 'breakdown' | 'nis2' | 'iso' | 'matrix' | 'audit'
|
||||
|
||||
interface Tab {
|
||||
id: TabId
|
||||
label: string
|
||||
}
|
||||
|
||||
const TABS: Tab[] = [
|
||||
{ id: 'overview', label: 'Uebersicht' },
|
||||
{ id: 'breakdown', label: 'Breakdown' },
|
||||
{ id: 'nis2', label: 'NIS2' },
|
||||
{ id: 'iso', label: 'ISO 27001' },
|
||||
{ id: 'matrix', label: 'Matrix' },
|
||||
{ id: 'audit', label: 'Audit Trail' },
|
||||
]
|
||||
|
||||
// =============================================================================
|
||||
// HELPER 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 overflow-x-auto" 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 whitespace-nowrap ${
|
||||
activeTab === tab.id
|
||||
? 'border-purple-600 text-purple-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ScoreCircle({ score, size = 144, label }: { score: number; size?: number; label?: string }) {
|
||||
const radius = (size / 2) - 12
|
||||
const circumference = 2 * Math.PI * radius
|
||||
const strokeDashoffset = circumference - (score / 100) * circumference
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col items-center">
|
||||
<svg className="-rotate-90" width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
|
||||
<circle cx={size/2} cy={size/2} r={radius} stroke="#e5e7eb" strokeWidth="8" fill="none" />
|
||||
<circle
|
||||
cx={size/2} cy={size/2} r={radius}
|
||||
stroke={getScoreRingColor(score)}
|
||||
strokeWidth="8" fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
className="transition-all duration-1000"
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className={`text-3xl font-bold ${getScoreColor(score)}`}>{score.toFixed(1)}</span>
|
||||
{label && <span className="text-xs text-gray-500 mt-1">{label}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MaturityBadge({ level }: { level: MaturityLevel }) {
|
||||
const info = MATURITY_INFO[level] || MATURITY_INFO.HIGH_RISK
|
||||
return (
|
||||
<span className={`inline-flex items-center px-3 py-1 rounded-full text-sm font-medium ${info.bgColor} ${info.color} border ${info.borderColor}`}>
|
||||
{info.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function AreaScoreBar({ name, score, weight }: { name: string; score: number; weight: number }) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="font-medium text-gray-700">{name}</span>
|
||||
<span className={`font-semibold ${getScoreColor(score)}`}>{score.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-3">
|
||||
<div
|
||||
className="h-3 rounded-full transition-all duration-700"
|
||||
style={{ width: `${Math.min(score, 100)}%`, backgroundColor: getScoreRingColor(score) }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">Gewichtung: {(weight * 100).toFixed(0)}%</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LoadingSpinner() {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ErrorMessage({ message, onRetry }: { message: string; onRetry?: () => void }) {
|
||||
return (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-red-700">
|
||||
<p>{message}</p>
|
||||
{onRetry && (
|
||||
<button onClick={onRetry} className="mt-2 text-sm underline hover:no-underline">
|
||||
Erneut versuchen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// TAB: OVERVIEW
|
||||
// =============================================================================
|
||||
|
||||
function OverviewTab({ gci, history, profiles, selectedProfile, onProfileChange }: {
|
||||
gci: GCIResult
|
||||
history: GCIHistoryResponse | null
|
||||
profiles: WeightProfile[]
|
||||
selectedProfile: string
|
||||
onProfileChange: (p: string) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Profile Selector */}
|
||||
{profiles.length > 0 && (
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="text-sm font-medium text-gray-700">Gewichtungsprofil:</label>
|
||||
<select
|
||||
value={selectedProfile}
|
||||
onChange={e => onProfileChange(e.target.value)}
|
||||
className="rounded-md border-gray-300 shadow-sm text-sm focus:border-purple-500 focus:ring-purple-500"
|
||||
>
|
||||
{profiles.map(p => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Score */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex flex-col md:flex-row items-center gap-8">
|
||||
<ScoreCircle score={gci.gci_score} label="GCI Score" />
|
||||
<div className="flex-1 space-y-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Gesamt-Compliance-Index</h3>
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
<MaturityBadge level={gci.maturity_level} />
|
||||
<span className="text-sm text-gray-500">
|
||||
Berechnet: {new Date(gci.calculated_at).toLocaleString('de-DE')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">
|
||||
{MATURITY_INFO[gci.maturity_level]?.description || ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Area Scores */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-base font-semibold text-gray-900 mb-4">Regulierungsbereiche</h3>
|
||||
<div className="space-y-4">
|
||||
{gci.area_scores.map(area => (
|
||||
<AreaScoreBar
|
||||
key={area.regulation_id}
|
||||
name={area.regulation_name}
|
||||
score={area.score}
|
||||
weight={area.weight}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* History Chart (simplified) */}
|
||||
{history && history.snapshots.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-base font-semibold text-gray-900 mb-4">Verlauf</h3>
|
||||
<div className="flex items-end gap-2 h-32">
|
||||
{history.snapshots.map((snap, i) => (
|
||||
<div key={i} className="flex-1 flex flex-col items-center gap-1">
|
||||
<span className="text-xs text-gray-500">{snap.score.toFixed(0)}</span>
|
||||
<div
|
||||
className="w-full rounded-t transition-all duration-500"
|
||||
style={{
|
||||
height: `${(snap.score / 100) * 100}%`,
|
||||
backgroundColor: getScoreRingColor(snap.score),
|
||||
minHeight: '4px',
|
||||
}}
|
||||
/>
|
||||
<span className="text-[10px] text-gray-400">
|
||||
{new Date(snap.calculated_at).toLocaleDateString('de-DE', { month: 'short' })}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Adjustments */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-4">
|
||||
<div className="text-sm text-gray-500">Kritikalitaets-Multiplikator</div>
|
||||
<div className="text-2xl font-bold text-gray-900">{gci.criticality_multiplier.toFixed(2)}x</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-4">
|
||||
<div className="text-sm text-gray-500">Incident-Korrektur</div>
|
||||
<div className={`text-2xl font-bold ${gci.incident_adjustment < 0 ? 'text-red-600' : 'text-green-600'}`}>
|
||||
{gci.incident_adjustment > 0 ? '+' : ''}{gci.incident_adjustment.toFixed(1)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// TAB: BREAKDOWN
|
||||
// =============================================================================
|
||||
|
||||
function BreakdownTab({ breakdown }: { breakdown: GCIBreakdown | null; loading: boolean }) {
|
||||
if (!breakdown) return <LoadingSpinner />
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Level 1: Modules */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-base font-semibold text-gray-900 mb-4">Level 1: Modul-Scores</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200">
|
||||
<th className="text-left py-2 pr-4 font-medium text-gray-600">Modul</th>
|
||||
<th className="text-left py-2 pr-4 font-medium text-gray-600">Kategorie</th>
|
||||
<th className="text-right py-2 pr-4 font-medium text-gray-600">Zugewiesen</th>
|
||||
<th className="text-right py-2 pr-4 font-medium text-gray-600">Abgeschlossen</th>
|
||||
<th className="text-right py-2 pr-4 font-medium text-gray-600">Raw Score</th>
|
||||
<th className="text-right py-2 pr-4 font-medium text-gray-600">Validitaet</th>
|
||||
<th className="text-right py-2 font-medium text-gray-600">Final</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{breakdown.level1_modules.map(m => (
|
||||
<tr key={m.module_id} className="border-b border-gray-100 hover:bg-gray-50">
|
||||
<td className="py-2 pr-4 font-medium text-gray-900">{m.module_name}</td>
|
||||
<td className="py-2 pr-4">
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-700">
|
||||
{m.category}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-right text-gray-600">{m.assigned}</td>
|
||||
<td className="py-2 pr-4 text-right text-gray-600">{m.completed}</td>
|
||||
<td className="py-2 pr-4 text-right text-gray-600">{(m.raw_score * 100).toFixed(1)}%</td>
|
||||
<td className="py-2 pr-4 text-right text-gray-600">{(m.validity_factor * 100).toFixed(0)}%</td>
|
||||
<td className={`py-2 text-right font-semibold ${getScoreColor(m.final_score * 100)}`}>
|
||||
{(m.final_score * 100).toFixed(1)}%
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Level 2: Areas */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-base font-semibold text-gray-900 mb-4">Level 2: Regulierungsbereiche (risikogewichtet)</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{breakdown.level2_areas.map(area => (
|
||||
<div key={area.area_id} className="border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<h4 className="font-medium text-gray-900">{area.area_name}</h4>
|
||||
<span className={`text-lg font-bold ${getScoreColor(area.area_score)}`}>
|
||||
{area.area_score.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{area.modules.map(m => (
|
||||
<div key={m.module_id} className="flex justify-between text-xs text-gray-500">
|
||||
<span>{m.module_name}</span>
|
||||
<span>{(m.final_score * 100).toFixed(0)}% (w:{m.risk_weight.toFixed(1)})</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// TAB: NIS2
|
||||
// =============================================================================
|
||||
|
||||
function NIS2Tab({ nis2 }: { nis2: NIS2Score | null }) {
|
||||
if (!nis2) return <LoadingSpinner />
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* NIS2 Overall */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex items-center gap-6">
|
||||
<ScoreCircle score={nis2.overall_score} size={120} label="NIS2" />
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">NIS2 Compliance Score</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Network and Information Security Directive 2 (EU 2022/2555)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* NIS2 Areas */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-base font-semibold text-gray-900 mb-4">NIS2 Bereiche</h3>
|
||||
<div className="space-y-3">
|
||||
{nis2.areas.map(area => (
|
||||
<AreaScoreBar key={area.area_id} name={area.area_name} score={area.score} weight={area.weight} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* NIS2 Roles */}
|
||||
{nis2.role_scores && nis2.role_scores.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-base font-semibold text-gray-900 mb-4">Rollen-Compliance</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{nis2.role_scores.map(role => (
|
||||
<div key={role.role_id} className="border border-gray-200 rounded-lg p-3">
|
||||
<div className="font-medium text-gray-900 text-sm">{role.role_name}</div>
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<span className={`text-lg font-bold ${getScoreColor(role.completion_rate * 100)}`}>
|
||||
{(role.completion_rate * 100).toFixed(0)}%
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{role.modules_completed}/{role.modules_required} Module
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-1.5 mt-2">
|
||||
<div
|
||||
className="h-1.5 rounded-full"
|
||||
style={{
|
||||
width: `${Math.min(role.completion_rate * 100, 100)}%`,
|
||||
backgroundColor: getScoreRingColor(role.completion_rate * 100),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// TAB: ISO 27001
|
||||
// =============================================================================
|
||||
|
||||
function ISOTab({ iso }: { iso: ISOGapAnalysis | null }) {
|
||||
if (!iso) return <LoadingSpinner />
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Coverage Overview */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex items-center gap-6">
|
||||
<ScoreCircle score={iso.coverage_percent} size={120} label="Abdeckung" />
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-gray-900">ISO 27001:2022 Gap-Analyse</h3>
|
||||
<div className="grid grid-cols-3 gap-4 mt-3">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-green-600">{iso.covered_full}</div>
|
||||
<div className="text-xs text-gray-500">Voll abgedeckt</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-yellow-600">{iso.covered_partial}</div>
|
||||
<div className="text-xs text-gray-500">Teilweise</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-red-600">{iso.not_covered}</div>
|
||||
<div className="text-xs text-gray-500">Nicht abgedeckt</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category Summaries */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-base font-semibold text-gray-900 mb-4">Kategorien</h3>
|
||||
<div className="space-y-3">
|
||||
{iso.category_summaries.map(cat => {
|
||||
const coveragePercent = cat.total_controls > 0
|
||||
? ((cat.covered_full + cat.covered_partial * 0.5) / cat.total_controls) * 100
|
||||
: 0
|
||||
return (
|
||||
<div key={cat.category_id} className="space-y-1">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="font-medium text-gray-700">{cat.category_id}: {cat.category_name}</span>
|
||||
<span className="text-gray-500">
|
||||
{cat.covered_full}/{cat.total_controls} Controls
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-3 flex overflow-hidden">
|
||||
<div className="h-3 bg-green-500" style={{ width: `${(cat.covered_full / cat.total_controls) * 100}%` }} />
|
||||
<div className="h-3 bg-yellow-500" style={{ width: `${(cat.covered_partial / cat.total_controls) * 100}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Gaps */}
|
||||
{iso.gaps && iso.gaps.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-base font-semibold text-gray-900 mb-4">
|
||||
Offene Gaps ({iso.gaps.length})
|
||||
</h3>
|
||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||
{iso.gaps.map(gap => (
|
||||
<div key={gap.control_id} className="flex items-start gap-3 p-3 border border-gray-100 rounded-lg hover:bg-gray-50">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${
|
||||
gap.priority === 'high' ? 'bg-red-100 text-red-700' :
|
||||
gap.priority === 'medium' ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-gray-100 text-gray-700'
|
||||
}`}>
|
||||
{gap.priority}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-gray-900">{gap.control_id}: {gap.control_name}</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">{gap.recommendation}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// TAB: MATRIX
|
||||
// =============================================================================
|
||||
|
||||
function MatrixTab({ matrix }: { matrix: GCIMatrixResponse | null }) {
|
||||
if (!matrix || !matrix.matrix) return <LoadingSpinner />
|
||||
|
||||
const regulations = matrix.matrix.length > 0 ? Object.keys(matrix.matrix[0].regulations) : []
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-base font-semibold text-gray-900 mb-4">Compliance-Matrix (Rollen x Regulierungen)</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200">
|
||||
<th className="text-left py-2 pr-4 font-medium text-gray-600">Rolle</th>
|
||||
{regulations.map(r => (
|
||||
<th key={r} className="text-center py-2 px-3 font-medium text-gray-600 uppercase">{r}</th>
|
||||
))}
|
||||
<th className="text-center py-2 px-3 font-medium text-gray-600">Gesamt</th>
|
||||
<th className="text-center py-2 px-3 font-medium text-gray-600">Module</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{matrix.matrix.map(entry => (
|
||||
<tr key={entry.role} className="border-b border-gray-100 hover:bg-gray-50">
|
||||
<td className="py-2 pr-4 font-medium text-gray-900">{entry.role_name}</td>
|
||||
{regulations.map(r => (
|
||||
<td key={r} className="py-2 px-3 text-center">
|
||||
<span className={`font-semibold ${getScoreColor(entry.regulations[r])}`}>
|
||||
{entry.regulations[r].toFixed(0)}%
|
||||
</span>
|
||||
</td>
|
||||
))}
|
||||
<td className="py-2 px-3 text-center">
|
||||
<span className={`font-bold ${getScoreColor(entry.overall_score)}`}>
|
||||
{entry.overall_score.toFixed(0)}%
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 px-3 text-center text-gray-500">
|
||||
{entry.completed_modules}/{entry.required_modules}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// TAB: AUDIT TRAIL
|
||||
// =============================================================================
|
||||
|
||||
function AuditTab({ gci }: { gci: GCIResult }) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-base font-semibold text-gray-900 mb-4">
|
||||
Audit Trail - Berechnung GCI {gci.gci_score.toFixed(1)}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
Jeder Schritt der GCI-Berechnung ist nachvollziehbar und prueffaehig dokumentiert.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{gci.audit_trail.map((entry, i) => (
|
||||
<div key={i} className="flex items-start gap-3 p-3 border border-gray-100 rounded-lg">
|
||||
<div className={`flex-shrink-0 w-2 h-2 rounded-full mt-1.5 ${
|
||||
entry.impact === 'positive' ? 'bg-green-500' :
|
||||
entry.impact === 'negative' ? 'bg-red-500' :
|
||||
'bg-gray-400'
|
||||
}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-900">{entry.factor}</span>
|
||||
<span className={`text-sm font-mono ${
|
||||
entry.impact === 'positive' ? 'text-green-600' :
|
||||
entry.impact === 'negative' ? 'text-red-600' :
|
||||
'text-gray-600'
|
||||
}`}>
|
||||
{entry.value > 0 ? '+' : ''}{entry.value.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-0.5">{entry.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN PAGE
|
||||
// =============================================================================
|
||||
|
||||
export default function GCIPage() {
|
||||
const [activeTab, setActiveTab] = useState<TabId>('overview')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const [gci, setGCI] = useState<GCIResult | null>(null)
|
||||
const [breakdown, setBreakdown] = useState<GCIBreakdown | null>(null)
|
||||
const [history, setHistory] = useState<GCIHistoryResponse | null>(null)
|
||||
const [matrix, setMatrix] = useState<GCIMatrixResponse | null>(null)
|
||||
const [nis2, setNIS2] = useState<NIS2Score | null>(null)
|
||||
const [iso, setISO] = useState<ISOGapAnalysis | null>(null)
|
||||
const [profiles, setProfiles] = useState<WeightProfile[]>([])
|
||||
const [selectedProfile, setSelectedProfile] = useState('default')
|
||||
|
||||
const loadData = useCallback(async (profile?: string) => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const [gciRes, historyRes, profilesRes] = await Promise.all([
|
||||
getGCIScore(profile),
|
||||
getGCIHistory(),
|
||||
getWeightProfiles(),
|
||||
])
|
||||
setGCI(gciRes)
|
||||
setHistory(historyRes)
|
||||
setProfiles(profilesRes.profiles || [])
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Fehler beim Laden der GCI-Daten')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadData(selectedProfile)
|
||||
}, [selectedProfile, loadData])
|
||||
|
||||
// Lazy-load tab data
|
||||
useEffect(() => {
|
||||
if (activeTab === 'breakdown' && !breakdown && gci) {
|
||||
getGCIBreakdown(selectedProfile).then(setBreakdown).catch(() => {})
|
||||
}
|
||||
if (activeTab === 'nis2' && !nis2) {
|
||||
getNIS2Score().then(setNIS2).catch(() => {})
|
||||
}
|
||||
if (activeTab === 'iso' && !iso) {
|
||||
getISOGapAnalysis().then(setISO).catch(() => {})
|
||||
}
|
||||
if (activeTab === 'matrix' && !matrix) {
|
||||
getGCIMatrix().then(setMatrix).catch(() => {})
|
||||
}
|
||||
}, [activeTab, breakdown, nis2, iso, matrix, gci, selectedProfile])
|
||||
|
||||
const handleProfileChange = (profile: string) => {
|
||||
setSelectedProfile(profile)
|
||||
setBreakdown(null) // reset breakdown to reload
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Gesamt-Compliance-Index (GCI)</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
4-stufiges, mathematisch fundiertes Compliance-Scoring
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => loadData(selectedProfile)}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 bg-purple-600 text-white rounded-lg text-sm font-medium hover:bg-purple-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{loading ? 'Lade...' : 'Aktualisieren'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<TabNavigation tabs={TABS} activeTab={activeTab} onTabChange={setActiveTab} />
|
||||
|
||||
{/* Content */}
|
||||
{error && <ErrorMessage message={error} onRetry={() => loadData(selectedProfile)} />}
|
||||
|
||||
{loading && !gci ? (
|
||||
<LoadingSpinner />
|
||||
) : gci ? (
|
||||
<div className="pb-8">
|
||||
{activeTab === 'overview' && (
|
||||
<OverviewTab
|
||||
gci={gci}
|
||||
history={history}
|
||||
profiles={profiles}
|
||||
selectedProfile={selectedProfile}
|
||||
onProfileChange={handleProfileChange}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'breakdown' && (
|
||||
<BreakdownTab breakdown={breakdown} loading={!breakdown} />
|
||||
)}
|
||||
{activeTab === 'nis2' && <NIS2Tab nis2={nis2} />}
|
||||
{activeTab === 'iso' && <ISOTab iso={iso} />}
|
||||
{activeTab === 'matrix' && <MatrixTab matrix={matrix} />}
|
||||
{activeTab === 'audit' && <AuditTab gci={gci} />}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,468 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
|
||||
interface Component {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
version: string
|
||||
description: string
|
||||
safety_relevant: boolean
|
||||
parent_id: string | null
|
||||
children: Component[]
|
||||
}
|
||||
|
||||
const COMPONENT_TYPES = [
|
||||
{ value: 'SW', label: 'Software (SW)' },
|
||||
{ value: 'FW', label: 'Firmware (FW)' },
|
||||
{ value: 'AI', label: 'KI-Modul (AI)' },
|
||||
{ value: 'HMI', label: 'Mensch-Maschine-Schnittstelle (HMI)' },
|
||||
{ value: 'SENSOR', label: 'Sensor' },
|
||||
{ value: 'ACTUATOR', label: 'Aktor' },
|
||||
{ value: 'CONTROLLER', label: 'Steuerung' },
|
||||
{ value: 'NETWORK', label: 'Netzwerk' },
|
||||
{ value: 'MECHANICAL', label: 'Mechanik' },
|
||||
{ value: 'ELECTRICAL', label: 'Elektrik' },
|
||||
{ value: 'OTHER', label: 'Sonstiges' },
|
||||
]
|
||||
|
||||
function ComponentTypeIcon({ type }: { type: string }) {
|
||||
const colors: Record<string, string> = {
|
||||
SW: 'bg-blue-100 text-blue-700',
|
||||
FW: 'bg-indigo-100 text-indigo-700',
|
||||
AI: 'bg-purple-100 text-purple-700',
|
||||
HMI: 'bg-pink-100 text-pink-700',
|
||||
SENSOR: 'bg-cyan-100 text-cyan-700',
|
||||
ACTUATOR: 'bg-orange-100 text-orange-700',
|
||||
CONTROLLER: 'bg-green-100 text-green-700',
|
||||
NETWORK: 'bg-yellow-100 text-yellow-700',
|
||||
MECHANICAL: 'bg-gray-100 text-gray-700',
|
||||
ELECTRICAL: 'bg-red-100 text-red-700',
|
||||
OTHER: 'bg-gray-100 text-gray-500',
|
||||
}
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${colors[type] || colors.OTHER}`}>
|
||||
{type}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function ComponentTreeNode({
|
||||
component,
|
||||
depth,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onAddChild,
|
||||
}: {
|
||||
component: Component
|
||||
depth: number
|
||||
onEdit: (c: Component) => void
|
||||
onDelete: (id: string) => void
|
||||
onAddChild: (parentId: string) => void
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(true)
|
||||
const hasChildren = component.children && component.children.length > 0
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className="flex items-center gap-2 py-2 px-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 group transition-colors"
|
||||
style={{ paddingLeft: `${depth * 24 + 12}px` }}
|
||||
>
|
||||
{/* Expand/collapse */}
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className={`w-5 h-5 flex items-center justify-center text-gray-400 ${hasChildren ? 'visible' : 'invisible'}`}
|
||||
>
|
||||
<svg
|
||||
className={`w-4 h-4 transition-transform ${expanded ? 'rotate-90' : ''}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<ComponentTypeIcon type={component.type} />
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">{component.name}</span>
|
||||
{component.version && (
|
||||
<span className="ml-2 text-xs text-gray-400">v{component.version}</span>
|
||||
)}
|
||||
{component.safety_relevant && (
|
||||
<span className="ml-2 inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-red-100 text-red-700">
|
||||
Sicherheitsrelevant
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{component.description && (
|
||||
<span className="text-xs text-gray-400 truncate max-w-[200px] hidden lg:block">
|
||||
{component.description}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={() => onAddChild(component.id)}
|
||||
title="Unterkomponente hinzufuegen"
|
||||
className="p-1 text-gray-400 hover:text-purple-600 hover:bg-purple-50 rounded transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onEdit(component)}
|
||||
title="Bearbeiten"
|
||||
className="p-1 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<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>
|
||||
<button
|
||||
onClick={() => onDelete(component.id)}
|
||||
title="Loeschen"
|
||||
className="p-1 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expanded && hasChildren && (
|
||||
<div>
|
||||
{component.children.map((child) => (
|
||||
<ComponentTreeNode
|
||||
key={child.id}
|
||||
component={child}
|
||||
depth={depth + 1}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onAddChild={onAddChild}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ComponentFormData {
|
||||
name: string
|
||||
type: string
|
||||
version: string
|
||||
description: string
|
||||
safety_relevant: boolean
|
||||
parent_id: string | null
|
||||
}
|
||||
|
||||
function ComponentForm({
|
||||
onSubmit,
|
||||
onCancel,
|
||||
initialData,
|
||||
parentId,
|
||||
}: {
|
||||
onSubmit: (data: ComponentFormData) => void
|
||||
onCancel: () => void
|
||||
initialData?: Component | null
|
||||
parentId?: string | null
|
||||
}) {
|
||||
const [formData, setFormData] = useState<ComponentFormData>({
|
||||
name: initialData?.name || '',
|
||||
type: initialData?.type || 'SW',
|
||||
version: initialData?.version || '',
|
||||
description: initialData?.description || '',
|
||||
safety_relevant: initialData?.safety_relevant || false,
|
||||
parent_id: parentId || initialData?.parent_id || null,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
{initialData ? 'Komponente bearbeiten' : parentId ? 'Unterkomponente hinzufuegen' : 'Neue Komponente'}
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="z.B. Bildverarbeitungsmodul"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Typ</label>
|
||||
<select
|
||||
value={formData.type}
|
||||
onChange={(e) => setFormData({ ...formData, type: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
>
|
||||
{COMPONENT_TYPES.map((t) => (
|
||||
<option key={t.value} value={t.value}>{t.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Version</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.version}
|
||||
onChange={(e) => setFormData({ ...formData, version: e.target.value })}
|
||||
placeholder="z.B. 1.2.0"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 pt-6">
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.safety_relevant}
|
||||
onChange={(e) => setFormData({ ...formData, safety_relevant: e.target.checked })}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-9 h-5 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-purple-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-red-500" />
|
||||
</label>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Sicherheitsrelevant</span>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Beschreibung</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="Kurze Beschreibung der Komponente..."
|
||||
rows={2}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => onSubmit(formData)}
|
||||
disabled={!formData.name}
|
||||
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
|
||||
formData.name
|
||||
? 'bg-purple-600 text-white hover:bg-purple-700'
|
||||
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
{initialData ? 'Aktualisieren' : 'Hinzufuegen'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function buildTree(components: Component[]): Component[] {
|
||||
const map = new Map<string, Component>()
|
||||
const roots: Component[] = []
|
||||
|
||||
components.forEach((c) => {
|
||||
map.set(c.id, { ...c, children: [] })
|
||||
})
|
||||
|
||||
components.forEach((c) => {
|
||||
const node = map.get(c.id)!
|
||||
if (c.parent_id && map.has(c.parent_id)) {
|
||||
map.get(c.parent_id)!.children.push(node)
|
||||
} else {
|
||||
roots.push(node)
|
||||
}
|
||||
})
|
||||
|
||||
return roots
|
||||
}
|
||||
|
||||
export default function ComponentsPage() {
|
||||
const params = useParams()
|
||||
const projectId = params.projectId as string
|
||||
const [components, setComponents] = useState<Component[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [editingComponent, setEditingComponent] = useState<Component | null>(null)
|
||||
const [addingParentId, setAddingParentId] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetchComponents()
|
||||
}, [projectId])
|
||||
|
||||
async function fetchComponents() {
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/components`)
|
||||
if (res.ok) {
|
||||
const json = await res.json()
|
||||
setComponents(json.components || json || [])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch components:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(data: ComponentFormData) {
|
||||
try {
|
||||
const url = editingComponent
|
||||
? `/api/sdk/v1/iace/projects/${projectId}/components/${editingComponent.id}`
|
||||
: `/api/sdk/v1/iace/projects/${projectId}/components`
|
||||
const method = editingComponent ? 'PUT' : 'POST'
|
||||
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
if (res.ok) {
|
||||
setShowForm(false)
|
||||
setEditingComponent(null)
|
||||
setAddingParentId(null)
|
||||
await fetchComponents()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to save component:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
if (!confirm('Komponente wirklich loeschen? Unterkomponenten werden ebenfalls entfernt.')) return
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/components/${id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
if (res.ok) {
|
||||
await fetchComponents()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to delete component:', err)
|
||||
}
|
||||
}
|
||||
|
||||
function handleEdit(component: Component) {
|
||||
setEditingComponent(component)
|
||||
setAddingParentId(null)
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
function handleAddChild(parentId: string) {
|
||||
setAddingParentId(parentId)
|
||||
setEditingComponent(null)
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
const tree = buildTree(components)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Komponenten</h1>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Erfassen Sie alle Software-, Firmware-, KI- und Hardware-Komponenten der Maschine.
|
||||
</p>
|
||||
</div>
|
||||
{!showForm && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowForm(true)
|
||||
setEditingComponent(null)
|
||||
setAddingParentId(null)
|
||||
}}
|
||||
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>
|
||||
Komponente hinzufuegen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
{showForm && (
|
||||
<ComponentForm
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={() => {
|
||||
setShowForm(false)
|
||||
setEditingComponent(null)
|
||||
setAddingParentId(null)
|
||||
}}
|
||||
initialData={editingComponent}
|
||||
parentId={addingParentId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Component Tree */}
|
||||
{tree.length > 0 ? (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 divide-y divide-gray-100 dark:divide-gray-700">
|
||||
<div className="px-4 py-3 bg-gray-50 dark:bg-gray-750 rounded-t-xl">
|
||||
<div className="flex items-center gap-2 text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<span className="w-5" />
|
||||
<span>Typ</span>
|
||||
<span className="flex-1">Name</span>
|
||||
<span className="hidden lg:block w-[200px]">Beschreibung</span>
|
||||
<span className="w-24">Aktionen</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="py-1">
|
||||
{tree.map((component) => (
|
||||
<ComponentTreeNode
|
||||
key={component.id}
|
||||
component={component}
|
||||
depth={0}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
onAddChild={handleAddChild}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
!showForm && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-12 text-center">
|
||||
<div className="w-16 h-16 mx-auto bg-purple-100 dark:bg-purple-900/30 rounded-full flex items-center justify-center mb-4">
|
||||
<svg className="w-8 h-8 text-purple-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Keine Komponenten erfasst</h3>
|
||||
<p className="mt-2 text-gray-500 max-w-md mx-auto">
|
||||
Beginnen Sie mit der Erfassung aller relevanten Komponenten Ihrer Maschine.
|
||||
Erstellen Sie eine hierarchische Struktur aus Software, Firmware, KI-Modulen und Hardware.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowForm(true)}
|
||||
className="mt-6 px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
Erste Komponente hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,628 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
|
||||
interface Hazard {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
component_id: string | null
|
||||
component_name: string | null
|
||||
category: string
|
||||
status: string
|
||||
severity: number
|
||||
exposure: number
|
||||
probability: number
|
||||
r_inherent: number
|
||||
risk_level: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface LibraryHazard {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
category: string
|
||||
default_severity: number
|
||||
default_exposure: number
|
||||
default_probability: number
|
||||
}
|
||||
|
||||
const HAZARD_CATEGORIES = [
|
||||
'mechanical', 'electrical', 'thermal', 'noise', 'vibration',
|
||||
'radiation', 'material', 'ergonomic', 'software', 'ai_specific',
|
||||
'cybersecurity', 'functional_safety', 'environmental',
|
||||
]
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
mechanical: 'Mechanisch',
|
||||
electrical: 'Elektrisch',
|
||||
thermal: 'Thermisch',
|
||||
noise: 'Laerm',
|
||||
vibration: 'Vibration',
|
||||
radiation: 'Strahlung',
|
||||
material: 'Stoffe/Materialien',
|
||||
ergonomic: 'Ergonomie',
|
||||
software: 'Software',
|
||||
ai_specific: 'KI-spezifisch',
|
||||
cybersecurity: 'Cybersecurity',
|
||||
functional_safety: 'Funktionale Sicherheit',
|
||||
environmental: 'Umgebung',
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
identified: 'Identifiziert',
|
||||
assessed: 'Bewertet',
|
||||
mitigated: 'Gemindert',
|
||||
accepted: 'Akzeptiert',
|
||||
closed: 'Geschlossen',
|
||||
}
|
||||
|
||||
function getRiskColor(level: string): string {
|
||||
switch (level) {
|
||||
case 'critical': return 'bg-red-100 text-red-700 border-red-200'
|
||||
case 'high': return 'bg-orange-100 text-orange-700 border-orange-200'
|
||||
case 'medium': return 'bg-yellow-100 text-yellow-700 border-yellow-200'
|
||||
case 'low': return 'bg-green-100 text-green-700 border-green-200'
|
||||
default: return 'bg-gray-100 text-gray-700 border-gray-200'
|
||||
}
|
||||
}
|
||||
|
||||
function getRiskLevel(r: number): string {
|
||||
if (r >= 100) return 'critical'
|
||||
if (r >= 50) return 'high'
|
||||
if (r >= 20) return 'medium'
|
||||
return 'low'
|
||||
}
|
||||
|
||||
function getRiskLevelLabel(level: string): string {
|
||||
switch (level) {
|
||||
case 'critical': return 'Kritisch'
|
||||
case 'high': return 'Hoch'
|
||||
case 'medium': return 'Mittel'
|
||||
case 'low': return 'Niedrig'
|
||||
default: return level
|
||||
}
|
||||
}
|
||||
|
||||
function RiskBadge({ level }: { level: string }) {
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium border ${getRiskColor(level)}`}>
|
||||
{getRiskLevelLabel(level)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
interface HazardFormData {
|
||||
name: string
|
||||
description: string
|
||||
category: string
|
||||
component_id: string
|
||||
severity: number
|
||||
exposure: number
|
||||
probability: number
|
||||
}
|
||||
|
||||
function HazardForm({
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: {
|
||||
onSubmit: (data: HazardFormData) => void
|
||||
onCancel: () => void
|
||||
}) {
|
||||
const [formData, setFormData] = useState<HazardFormData>({
|
||||
name: '',
|
||||
description: '',
|
||||
category: 'mechanical',
|
||||
component_id: '',
|
||||
severity: 3,
|
||||
exposure: 3,
|
||||
probability: 3,
|
||||
})
|
||||
|
||||
const rInherent = formData.severity * formData.exposure * formData.probability
|
||||
const riskLevel = getRiskLevel(rInherent)
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">Neue Gefaehrdung</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Bezeichnung *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="z.B. Quetschung durch Roboterarm"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Kategorie</label>
|
||||
<select
|
||||
value={formData.category}
|
||||
onChange={(e) => setFormData({ ...formData, category: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
>
|
||||
{HAZARD_CATEGORIES.map((cat) => (
|
||||
<option key={cat} value={cat}>{CATEGORY_LABELS[cat] || cat}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Beschreibung</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
rows={2}
|
||||
placeholder="Detaillierte Beschreibung der Gefaehrdung..."
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* S/E/P Sliders */}
|
||||
<div className="bg-gray-50 dark:bg-gray-750 rounded-lg p-4">
|
||||
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">Risikobewertung (S x E x P)</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-1">
|
||||
Schwere (S): <span className="font-bold">{formData.severity}</span>
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={5}
|
||||
value={formData.severity}
|
||||
onChange={(e) => setFormData({ ...formData, severity: Number(e.target.value) })}
|
||||
className="w-full accent-purple-600"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-400">
|
||||
<span>Gering</span>
|
||||
<span>Toedlich</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-1">
|
||||
Exposition (E): <span className="font-bold">{formData.exposure}</span>
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={5}
|
||||
value={formData.exposure}
|
||||
onChange={(e) => setFormData({ ...formData, exposure: Number(e.target.value) })}
|
||||
className="w-full accent-purple-600"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-400">
|
||||
<span>Selten</span>
|
||||
<span>Staendig</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-1">
|
||||
Wahrscheinlichkeit (P): <span className="font-bold">{formData.probability}</span>
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={5}
|
||||
value={formData.probability}
|
||||
onChange={(e) => setFormData({ ...formData, probability: Number(e.target.value) })}
|
||||
className="w-full accent-purple-600"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-400">
|
||||
<span>Unwahrscheinlich</span>
|
||||
<span>Sehr wahrscheinlich</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`mt-4 p-3 rounded-lg border ${getRiskColor(riskLevel)}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">R_inherent = S x E x P</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg font-bold">{rInherent}</span>
|
||||
<RiskBadge level={riskLevel} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => onSubmit(formData)}
|
||||
disabled={!formData.name}
|
||||
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
|
||||
formData.name
|
||||
? 'bg-purple-600 text-white hover:bg-purple-700'
|
||||
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
Hinzufuegen
|
||||
</button>
|
||||
<button onClick={onCancel} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LibraryModal({
|
||||
library,
|
||||
onAdd,
|
||||
onClose,
|
||||
}: {
|
||||
library: LibraryHazard[]
|
||||
onAdd: (item: LibraryHazard) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [search, setSearch] = useState('')
|
||||
const [filterCat, setFilterCat] = useState('')
|
||||
|
||||
const filtered = library.filter((h) => {
|
||||
const matchSearch = !search || h.name.toLowerCase().includes(search.toLowerCase()) || h.description.toLowerCase().includes(search.toLowerCase())
|
||||
const matchCat = !filterCat || h.category === filterCat
|
||||
return matchSearch && matchCat
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl w-full max-w-2xl max-h-[80vh] flex flex-col">
|
||||
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Gefaehrdungsbibliothek</h3>
|
||||
<button onClick={onClose} className="p-1 text-gray-400 hover:text-gray-600 rounded">
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Suchen..."
|
||||
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
/>
|
||||
<select
|
||||
value={filterCat}
|
||||
onChange={(e) => setFilterCat(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
>
|
||||
<option value="">Alle Kategorien</option>
|
||||
{HAZARD_CATEGORIES.map((cat) => (
|
||||
<option key={cat} value={cat}>{CATEGORY_LABELS[cat] || cat}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto p-4 space-y-2">
|
||||
{filtered.length > 0 ? (
|
||||
filtered.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center justify-between p-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-750"
|
||||
>
|
||||
<div className="flex-1 min-w-0 mr-3">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">{item.name}</div>
|
||||
<div className="text-xs text-gray-500 truncate">{item.description}</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-xs text-gray-400">{CATEGORY_LABELS[item.category] || item.category}</span>
|
||||
<span className="text-xs text-gray-400">S:{item.default_severity} E:{item.default_exposure} P:{item.default_probability}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onAdd(item)}
|
||||
className="flex-shrink-0 px-3 py-1.5 text-xs bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
Hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-500">Keine Eintraege gefunden</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function HazardsPage() {
|
||||
const params = useParams()
|
||||
const projectId = params.projectId as string
|
||||
const [hazards, setHazards] = useState<Hazard[]>([])
|
||||
const [library, setLibrary] = useState<LibraryHazard[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [showLibrary, setShowLibrary] = useState(false)
|
||||
const [suggestingAI, setSuggestingAI] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetchHazards()
|
||||
}, [projectId])
|
||||
|
||||
async function fetchHazards() {
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/hazards`)
|
||||
if (res.ok) {
|
||||
const json = await res.json()
|
||||
setHazards(json.hazards || json || [])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch hazards:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchLibrary() {
|
||||
try {
|
||||
const res = await fetch('/api/sdk/v1/iace/hazard-library')
|
||||
if (res.ok) {
|
||||
const json = await res.json()
|
||||
setLibrary(json.hazards || json || [])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch hazard library:', err)
|
||||
}
|
||||
setShowLibrary(true)
|
||||
}
|
||||
|
||||
async function handleAddFromLibrary(item: LibraryHazard) {
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/hazards`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
category: item.category,
|
||||
severity: item.default_severity,
|
||||
exposure: item.default_exposure,
|
||||
probability: item.default_probability,
|
||||
}),
|
||||
})
|
||||
if (res.ok) {
|
||||
await fetchHazards()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to add from library:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(data: HazardFormData) {
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/hazards`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
if (res.ok) {
|
||||
setShowForm(false)
|
||||
await fetchHazards()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to add hazard:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAISuggestions() {
|
||||
setSuggestingAI(true)
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/hazards/suggest`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
if (res.ok) {
|
||||
await fetchHazards()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to get AI suggestions:', err)
|
||||
} finally {
|
||||
setSuggestingAI(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
if (!confirm('Gefaehrdung wirklich loeschen?')) return
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/hazards/${id}`, { method: 'DELETE' })
|
||||
if (res.ok) {
|
||||
await fetchHazards()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to delete hazard:', err)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Hazard Log</h1>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Gefaehrdungsanalyse mit Risikobewertung nach S x E x P Methode.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleAISuggestions}
|
||||
disabled={suggestingAI}
|
||||
className="flex items-center gap-2 px-3 py-2 border border-purple-300 text-purple-700 rounded-lg hover:bg-purple-50 transition-colors disabled:opacity-50 text-sm"
|
||||
>
|
||||
{suggestingAI ? (
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-purple-600" />
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
)}
|
||||
KI-Vorschlaege
|
||||
</button>
|
||||
<button
|
||||
onClick={fetchLibrary}
|
||||
className="flex items-center gap-2 px-3 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors text-sm"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<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>
|
||||
Aus Bibliothek
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowForm(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors text-sm"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
Manuell hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
{hazards.length > 0 && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4 text-center">
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">{hazards.length}</div>
|
||||
<div className="text-xs text-gray-500">Gesamt</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-red-200 p-4 text-center">
|
||||
<div className="text-2xl font-bold text-red-600">{hazards.filter((h) => h.risk_level === 'critical').length}</div>
|
||||
<div className="text-xs text-red-600">Kritisch</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-orange-200 p-4 text-center">
|
||||
<div className="text-2xl font-bold text-orange-600">{hazards.filter((h) => h.risk_level === 'high').length}</div>
|
||||
<div className="text-xs text-orange-600">Hoch</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-yellow-200 p-4 text-center">
|
||||
<div className="text-2xl font-bold text-yellow-600">{hazards.filter((h) => h.risk_level === 'medium').length}</div>
|
||||
<div className="text-xs text-yellow-600">Mittel</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-green-200 p-4 text-center">
|
||||
<div className="text-2xl font-bold text-green-600">{hazards.filter((h) => h.risk_level === 'low').length}</div>
|
||||
<div className="text-xs text-green-600">Niedrig</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form */}
|
||||
{showForm && (
|
||||
<HazardForm onSubmit={handleSubmit} onCancel={() => setShowForm(false)} />
|
||||
)}
|
||||
|
||||
{/* Library Modal */}
|
||||
{showLibrary && (
|
||||
<LibraryModal
|
||||
library={library}
|
||||
onAdd={handleAddFromLibrary}
|
||||
onClose={() => setShowLibrary(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Hazard Table */}
|
||||
{hazards.length > 0 ? (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 dark:bg-gray-750 border-b border-gray-200 dark:border-gray-700">
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Bezeichnung</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Kategorie</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Komponente</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">S</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">E</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">P</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">R</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Risiko</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{hazards
|
||||
.sort((a, b) => b.r_inherent - a.r_inherent)
|
||||
.map((hazard) => (
|
||||
<tr key={hazard.id} className="hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors">
|
||||
<td className="px-4 py-3">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">{hazard.name}</div>
|
||||
{hazard.description && (
|
||||
<div className="text-xs text-gray-500 truncate max-w-[250px]">{hazard.description}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600">{CATEGORY_LABELS[hazard.category] || hazard.category}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600">{hazard.component_name || '--'}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-900 dark:text-white text-center font-medium">{hazard.severity}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-900 dark:text-white text-center font-medium">{hazard.exposure}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-900 dark:text-white text-center font-medium">{hazard.probability}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-900 dark:text-white text-center font-bold">{hazard.r_inherent}</td>
|
||||
<td className="px-4 py-3"><RiskBadge level={hazard.risk_level} /></td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-xs text-gray-500">{STATUS_LABELS[hazard.status] || hazard.status}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button
|
||||
onClick={() => handleDelete(hazard.id)}
|
||||
className="p-1 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
!showForm && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-12 text-center">
|
||||
<div className="w-16 h-16 mx-auto bg-orange-100 dark:bg-orange-900/30 rounded-full flex items-center justify-center mb-4">
|
||||
<svg className="w-8 h-8 text-orange-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<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>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Kein Hazard Log vorhanden</h3>
|
||||
<p className="mt-2 text-gray-500 max-w-md mx-auto">
|
||||
Beginnen Sie mit der systematischen Erfassung von Gefaehrdungen. Nutzen Sie die Bibliothek
|
||||
oder KI-Vorschlaege als Ausgangspunkt.
|
||||
</p>
|
||||
<div className="mt-6 flex items-center justify-center gap-3">
|
||||
<button
|
||||
onClick={() => setShowForm(true)}
|
||||
className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
Manuell hinzufuegen
|
||||
</button>
|
||||
<button
|
||||
onClick={fetchLibrary}
|
||||
className="px-6 py-3 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Bibliothek oeffnen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,413 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
|
||||
interface Mitigation {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
reduction_type: 'design' | 'protection' | 'information'
|
||||
status: 'planned' | 'implemented' | 'verified'
|
||||
linked_hazard_ids: string[]
|
||||
linked_hazard_names: string[]
|
||||
created_at: string
|
||||
verified_at: string | null
|
||||
verified_by: string | null
|
||||
}
|
||||
|
||||
interface Hazard {
|
||||
id: string
|
||||
name: string
|
||||
risk_level: string
|
||||
}
|
||||
|
||||
const REDUCTION_TYPES = {
|
||||
design: {
|
||||
label: 'Design',
|
||||
description: 'Inhaerent sichere Konstruktion',
|
||||
color: 'border-blue-200 bg-blue-50',
|
||||
headerColor: 'bg-blue-100 text-blue-800',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 4a2 2 0 114 0v1a1 1 0 001 1h3a1 1 0 011 1v3a1 1 0 01-1 1h-1a2 2 0 100 4h1a1 1 0 011 1v3a1 1 0 01-1 1h-3a1 1 0 01-1-1v-1a2 2 0 10-4 0v1a1 1 0 01-1 1H7a1 1 0 01-1-1v-3a1 1 0 00-1-1H4a2 2 0 110-4h1a1 1 0 001-1V7a1 1 0 011-1h3a1 1 0 001-1V4z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
protection: {
|
||||
label: 'Schutz',
|
||||
description: 'Technische Schutzmassnahmen',
|
||||
color: 'border-green-200 bg-green-50',
|
||||
headerColor: 'bg-green-100 text-green-800',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<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>
|
||||
),
|
||||
},
|
||||
information: {
|
||||
label: 'Information',
|
||||
description: 'Hinweise und Schulungen',
|
||||
color: 'border-yellow-200 bg-yellow-50',
|
||||
headerColor: 'bg-yellow-100 text-yellow-800',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<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>
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const colors: Record<string, string> = {
|
||||
planned: 'bg-gray-100 text-gray-700',
|
||||
implemented: 'bg-blue-100 text-blue-700',
|
||||
verified: 'bg-green-100 text-green-700',
|
||||
}
|
||||
const labels: Record<string, string> = {
|
||||
planned: 'Geplant',
|
||||
implemented: 'Umgesetzt',
|
||||
verified: 'Verifiziert',
|
||||
}
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${colors[status] || colors.planned}`}>
|
||||
{labels[status] || status}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
interface MitigationFormData {
|
||||
title: string
|
||||
description: string
|
||||
reduction_type: 'design' | 'protection' | 'information'
|
||||
linked_hazard_ids: string[]
|
||||
}
|
||||
|
||||
function MitigationForm({
|
||||
onSubmit,
|
||||
onCancel,
|
||||
hazards,
|
||||
preselectedType,
|
||||
}: {
|
||||
onSubmit: (data: MitigationFormData) => void
|
||||
onCancel: () => void
|
||||
hazards: Hazard[]
|
||||
preselectedType?: 'design' | 'protection' | 'information'
|
||||
}) {
|
||||
const [formData, setFormData] = useState<MitigationFormData>({
|
||||
title: '',
|
||||
description: '',
|
||||
reduction_type: preselectedType || 'design',
|
||||
linked_hazard_ids: [],
|
||||
})
|
||||
|
||||
function toggleHazard(id: string) {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
linked_hazard_ids: prev.linked_hazard_ids.includes(id)
|
||||
? prev.linked_hazard_ids.filter((h) => h !== id)
|
||||
: [...prev.linked_hazard_ids, id],
|
||||
}))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">Neue Massnahme</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Titel *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||
placeholder="z.B. Lichtvorhang an Gefahrenstelle"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Reduktionstyp</label>
|
||||
<select
|
||||
value={formData.reduction_type}
|
||||
onChange={(e) => setFormData({ ...formData, reduction_type: e.target.value as MitigationFormData['reduction_type'] })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
>
|
||||
<option value="design">Design - Inhaerent sichere Konstruktion</option>
|
||||
<option value="protection">Schutz - Technische Schutzmassnahmen</option>
|
||||
<option value="information">Information - Hinweise und Schulungen</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Beschreibung</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
rows={2}
|
||||
placeholder="Detaillierte Beschreibung der Massnahme..."
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
{hazards.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Verknuepfte Gefaehrdungen</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{hazards.map((h) => (
|
||||
<button
|
||||
key={h.id}
|
||||
onClick={() => toggleHazard(h.id)}
|
||||
className={`px-3 py-1.5 text-xs rounded-lg border transition-colors ${
|
||||
formData.linked_hazard_ids.includes(h.id)
|
||||
? 'border-purple-400 bg-purple-50 text-purple-700'
|
||||
: 'border-gray-200 bg-white text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{h.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4 flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => onSubmit(formData)}
|
||||
disabled={!formData.title}
|
||||
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
|
||||
formData.title
|
||||
? 'bg-purple-600 text-white hover:bg-purple-700'
|
||||
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
Hinzufuegen
|
||||
</button>
|
||||
<button onClick={onCancel} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MitigationCard({
|
||||
mitigation,
|
||||
onVerify,
|
||||
onDelete,
|
||||
}: {
|
||||
mitigation: Mitigation
|
||||
onVerify: (id: string) => void
|
||||
onDelete: (id: string) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-white">{mitigation.title}</h4>
|
||||
<StatusBadge status={mitigation.status} />
|
||||
</div>
|
||||
{mitigation.description && (
|
||||
<p className="text-xs text-gray-500 mb-3">{mitigation.description}</p>
|
||||
)}
|
||||
{mitigation.linked_hazard_names.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{mitigation.linked_hazard_names.map((name, i) => (
|
||||
<span key={i} className="inline-flex items-center px-1.5 py-0.5 rounded text-xs bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400">
|
||||
{name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{mitigation.status !== 'verified' && (
|
||||
<button
|
||||
onClick={() => onVerify(mitigation.id)}
|
||||
className="text-xs px-2.5 py-1 bg-green-50 text-green-700 border border-green-200 rounded-lg hover:bg-green-100 transition-colors"
|
||||
>
|
||||
Verifizieren
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onDelete(mitigation.id)}
|
||||
className="text-xs px-2.5 py-1 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||
>
|
||||
Loeschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function MitigationsPage() {
|
||||
const params = useParams()
|
||||
const projectId = params.projectId as string
|
||||
const [mitigations, setMitigations] = useState<Mitigation[]>([])
|
||||
const [hazards, setHazards] = useState<Hazard[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [preselectedType, setPreselectedType] = useState<'design' | 'protection' | 'information' | undefined>()
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [projectId])
|
||||
|
||||
async function fetchData() {
|
||||
try {
|
||||
const [mitRes, hazRes] = await Promise.all([
|
||||
fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations`),
|
||||
fetch(`/api/sdk/v1/iace/projects/${projectId}/hazards`),
|
||||
])
|
||||
if (mitRes.ok) {
|
||||
const json = await mitRes.json()
|
||||
setMitigations(json.mitigations || json || [])
|
||||
}
|
||||
if (hazRes.ok) {
|
||||
const json = await hazRes.json()
|
||||
setHazards((json.hazards || json || []).map((h: Hazard) => ({ id: h.id, name: h.name, risk_level: h.risk_level })))
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch data:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(data: MitigationFormData) {
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
if (res.ok) {
|
||||
setShowForm(false)
|
||||
setPreselectedType(undefined)
|
||||
await fetchData()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to add mitigation:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleVerify(id: string) {
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations/${id}/verify`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
if (res.ok) {
|
||||
await fetchData()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to verify mitigation:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
if (!confirm('Massnahme wirklich loeschen?')) return
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations/${id}`, { method: 'DELETE' })
|
||||
if (res.ok) {
|
||||
await fetchData()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to delete mitigation:', err)
|
||||
}
|
||||
}
|
||||
|
||||
function handleAddForType(type: 'design' | 'protection' | 'information') {
|
||||
setPreselectedType(type)
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
const byType = {
|
||||
design: mitigations.filter((m) => m.reduction_type === 'design'),
|
||||
protection: mitigations.filter((m) => m.reduction_type === 'protection'),
|
||||
information: mitigations.filter((m) => m.reduction_type === 'information'),
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Massnahmen</h1>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Risikominderung nach dem 3-Stufen-Verfahren: Design, Schutz, Information.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setPreselectedType(undefined)
|
||||
setShowForm(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 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
Massnahme hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
{showForm && (
|
||||
<MitigationForm
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={() => {
|
||||
setShowForm(false)
|
||||
setPreselectedType(undefined)
|
||||
}}
|
||||
hazards={hazards}
|
||||
preselectedType={preselectedType}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 3-Column Layout */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{(['design', 'protection', 'information'] as const).map((type) => {
|
||||
const config = REDUCTION_TYPES[type]
|
||||
const items = byType[type]
|
||||
return (
|
||||
<div key={type} className={`rounded-xl border ${config.color} p-4`}>
|
||||
<div className={`flex items-center gap-2 px-3 py-2 rounded-lg ${config.headerColor} mb-4`}>
|
||||
{config.icon}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">{config.label}</h3>
|
||||
<p className="text-xs opacity-75">{config.description}</p>
|
||||
</div>
|
||||
<span className="ml-auto text-sm font-bold">{items.length}</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{items.map((m) => (
|
||||
<MitigationCard
|
||||
key={m.id}
|
||||
mitigation={m}
|
||||
onVerify={handleVerify}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => handleAddForType(type)}
|
||||
className="mt-3 w-full py-2 text-sm text-gray-500 hover:text-purple-600 hover:bg-white rounded-lg border border-dashed border-gray-300 hover:border-purple-300 transition-colors"
|
||||
>
|
||||
+ Massnahme hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,512 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
|
||||
interface MonitoringEvent {
|
||||
id: string
|
||||
event_type: 'incident' | 'update' | 'drift_alert' | 'regulation_change'
|
||||
title: string
|
||||
description: string
|
||||
severity: 'low' | 'medium' | 'high' | 'critical'
|
||||
status: 'open' | 'investigating' | 'resolved' | 'closed'
|
||||
created_at: string
|
||||
resolved_at: string | null
|
||||
resolved_by: string | null
|
||||
resolution_notes: string | null
|
||||
}
|
||||
|
||||
const EVENT_TYPE_CONFIG: Record<string, { label: string; color: string; bgColor: string; icon: string }> = {
|
||||
incident: {
|
||||
label: 'Vorfall',
|
||||
color: 'text-red-700',
|
||||
bgColor: 'bg-red-100',
|
||||
icon: '🚨',
|
||||
},
|
||||
update: {
|
||||
label: 'Update',
|
||||
color: 'text-blue-700',
|
||||
bgColor: 'bg-blue-100',
|
||||
icon: '🔄',
|
||||
},
|
||||
drift_alert: {
|
||||
label: 'Drift-Warnung',
|
||||
color: 'text-orange-700',
|
||||
bgColor: 'bg-orange-100',
|
||||
icon: '📉',
|
||||
},
|
||||
regulation_change: {
|
||||
label: 'Regulierungsaenderung',
|
||||
color: 'text-purple-700',
|
||||
bgColor: 'bg-purple-100',
|
||||
icon: '📜',
|
||||
},
|
||||
}
|
||||
|
||||
const SEVERITY_CONFIG: Record<string, { label: string; color: string }> = {
|
||||
low: { label: 'Niedrig', color: 'bg-green-100 text-green-700' },
|
||||
medium: { label: 'Mittel', color: 'bg-yellow-100 text-yellow-700' },
|
||||
high: { label: 'Hoch', color: 'bg-orange-100 text-orange-700' },
|
||||
critical: { label: 'Kritisch', color: 'bg-red-100 text-red-700' },
|
||||
}
|
||||
|
||||
const STATUS_CONFIG: Record<string, { label: string; color: string }> = {
|
||||
open: { label: 'Offen', color: 'bg-red-100 text-red-700' },
|
||||
investigating: { label: 'In Untersuchung', color: 'bg-yellow-100 text-yellow-700' },
|
||||
resolved: { label: 'Geloest', color: 'bg-green-100 text-green-700' },
|
||||
closed: { label: 'Geschlossen', color: 'bg-gray-100 text-gray-700' },
|
||||
}
|
||||
|
||||
function EventTypeBadge({ type }: { type: string }) {
|
||||
const config = EVENT_TYPE_CONFIG[type] || EVENT_TYPE_CONFIG.incident
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${config.bgColor} ${config.color}`}>
|
||||
{config.icon} {config.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function SeverityBadge({ severity }: { severity: string }) {
|
||||
const config = SEVERITY_CONFIG[severity] || SEVERITY_CONFIG.low
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${config.color}`}>
|
||||
{config.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const config = STATUS_CONFIG[status] || STATUS_CONFIG.open
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${config.color}`}>
|
||||
{config.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
interface EventFormData {
|
||||
event_type: string
|
||||
title: string
|
||||
description: string
|
||||
severity: string
|
||||
}
|
||||
|
||||
function EventForm({
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: {
|
||||
onSubmit: (data: EventFormData) => void
|
||||
onCancel: () => void
|
||||
}) {
|
||||
const [formData, setFormData] = useState<EventFormData>({
|
||||
event_type: 'incident',
|
||||
title: '',
|
||||
description: '',
|
||||
severity: 'medium',
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">Neues Monitoring-Ereignis</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Titel *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||
placeholder="z.B. KI-Modell Drift erkannt"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Typ</label>
|
||||
<select
|
||||
value={formData.event_type}
|
||||
onChange={(e) => setFormData({ ...formData, event_type: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
>
|
||||
<option value="incident">Vorfall</option>
|
||||
<option value="update">Update</option>
|
||||
<option value="drift_alert">Drift-Warnung</option>
|
||||
<option value="regulation_change">Regulierungsaenderung</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Schwere</label>
|
||||
<select
|
||||
value={formData.severity}
|
||||
onChange={(e) => setFormData({ ...formData, severity: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
>
|
||||
<option value="low">Niedrig</option>
|
||||
<option value="medium">Mittel</option>
|
||||
<option value="high">Hoch</option>
|
||||
<option value="critical">Kritisch</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Beschreibung</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
rows={3}
|
||||
placeholder="Beschreiben Sie das Ereignis..."
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => onSubmit(formData)}
|
||||
disabled={!formData.title}
|
||||
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
|
||||
formData.title
|
||||
? 'bg-purple-600 text-white hover:bg-purple-700'
|
||||
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
Ereignis erfassen
|
||||
</button>
|
||||
<button onClick={onCancel} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ResolveModal({
|
||||
event,
|
||||
onSubmit,
|
||||
onClose,
|
||||
}: {
|
||||
event: MonitoringEvent
|
||||
onSubmit: (id: string, notes: string) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [notes, setNotes] = useState('')
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl w-full max-w-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Ereignis loesen: {event.title}
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Loesung / Massnahmen
|
||||
</label>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
rows={4}
|
||||
placeholder="Beschreiben Sie die durchgefuehrten Massnahmen..."
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => onSubmit(event.id, notes)}
|
||||
disabled={!notes}
|
||||
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
|
||||
notes
|
||||
? 'bg-green-600 text-white hover:bg-green-700'
|
||||
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
Als geloest markieren
|
||||
</button>
|
||||
<button onClick={onClose} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TimelineEvent({
|
||||
event,
|
||||
onResolve,
|
||||
}: {
|
||||
event: MonitoringEvent
|
||||
onResolve: (event: MonitoringEvent) => void
|
||||
}) {
|
||||
const typeConfig = EVENT_TYPE_CONFIG[event.event_type] || EVENT_TYPE_CONFIG.incident
|
||||
const lineColor = event.status === 'resolved' || event.status === 'closed' ? 'bg-green-300' : 'bg-gray-300'
|
||||
|
||||
return (
|
||||
<div className="relative flex gap-4 pb-8 last:pb-0">
|
||||
{/* Timeline line */}
|
||||
<div className="flex flex-col items-center">
|
||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center text-lg ${typeConfig.bgColor} flex-shrink-0`}>
|
||||
{typeConfig.icon}
|
||||
</div>
|
||||
<div className={`w-0.5 flex-1 ${lineColor} mt-2`} />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4 -mt-1">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white">{event.title}</h4>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<EventTypeBadge type={event.event_type} />
|
||||
<SeverityBadge severity={event.severity} />
|
||||
<StatusBadge status={event.status} />
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400 flex-shrink-0">
|
||||
{new Date(event.created_at).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{event.description && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-2">{event.description}</p>
|
||||
)}
|
||||
|
||||
{event.resolution_notes && (
|
||||
<div className="mt-3 p-3 bg-green-50 dark:bg-green-900/20 rounded-lg border border-green-200 dark:border-green-800">
|
||||
<div className="text-xs font-medium text-green-700 dark:text-green-400 mb-1">Loesung:</div>
|
||||
<p className="text-sm text-green-800 dark:text-green-300">{event.resolution_notes}</p>
|
||||
{event.resolved_at && (
|
||||
<div className="text-xs text-green-600 mt-1">
|
||||
Geloest am {new Date(event.resolved_at).toLocaleDateString('de-DE')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(event.status === 'open' || event.status === 'investigating') && (
|
||||
<div className="mt-3">
|
||||
<button
|
||||
onClick={() => onResolve(event)}
|
||||
className="text-sm px-3 py-1.5 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
|
||||
>
|
||||
Loesen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function MonitoringPage() {
|
||||
const params = useParams()
|
||||
const projectId = params.projectId as string
|
||||
const [events, setEvents] = useState<MonitoringEvent[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [resolvingEvent, setResolvingEvent] = useState<MonitoringEvent | null>(null)
|
||||
const [filterType, setFilterType] = useState('')
|
||||
const [filterStatus, setFilterStatus] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
fetchEvents()
|
||||
}, [projectId])
|
||||
|
||||
async function fetchEvents() {
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/monitoring`)
|
||||
if (res.ok) {
|
||||
const json = await res.json()
|
||||
setEvents(json.events || json || [])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch monitoring events:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(data: EventFormData) {
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/monitoring`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
if (res.ok) {
|
||||
setShowForm(false)
|
||||
await fetchEvents()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to add event:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResolve(id: string, notes: string) {
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/monitoring/${id}/resolve`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ resolution_notes: notes }),
|
||||
})
|
||||
if (res.ok) {
|
||||
setResolvingEvent(null)
|
||||
await fetchEvents()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to resolve event:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const filteredEvents = events.filter((e) => {
|
||||
const matchType = !filterType || e.event_type === filterType
|
||||
const matchStatus = !filterStatus || e.status === filterStatus
|
||||
return matchType && matchStatus
|
||||
})
|
||||
|
||||
const openCount = events.filter((e) => e.status === 'open' || e.status === 'investigating').length
|
||||
const resolvedCount = events.filter((e) => e.status === 'resolved' || e.status === 'closed').length
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Monitoring</h1>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Post-Market Surveillance -- Ueberwachung von Vorfaellen, Updates, Drift und Regulierungsaenderungen.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowForm(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 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
Ereignis erfassen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
{events.length > 0 && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4 text-center">
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">{events.length}</div>
|
||||
<div className="text-xs text-gray-500">Gesamt</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-red-200 p-4 text-center">
|
||||
<div className="text-2xl font-bold text-red-600">{openCount}</div>
|
||||
<div className="text-xs text-red-600">Offen</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-green-200 p-4 text-center">
|
||||
<div className="text-2xl font-bold text-green-600">{resolvedCount}</div>
|
||||
<div className="text-xs text-green-600">Geloest</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-orange-200 p-4 text-center">
|
||||
<div className="text-2xl font-bold text-orange-600">
|
||||
{events.filter((e) => e.severity === 'critical' || e.severity === 'high').length}
|
||||
</div>
|
||||
<div className="text-xs text-orange-600">Hoch/Kritisch</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
{events.length > 0 && (
|
||||
<div className="flex items-center gap-3">
|
||||
<select
|
||||
value={filterType}
|
||||
onChange={(e) => setFilterType(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
>
|
||||
<option value="">Alle Typen</option>
|
||||
<option value="incident">Vorfaelle</option>
|
||||
<option value="update">Updates</option>
|
||||
<option value="drift_alert">Drift-Warnungen</option>
|
||||
<option value="regulation_change">Regulierungsaenderungen</option>
|
||||
</select>
|
||||
<select
|
||||
value={filterStatus}
|
||||
onChange={(e) => setFilterStatus(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
>
|
||||
<option value="">Alle Status</option>
|
||||
<option value="open">Offen</option>
|
||||
<option value="investigating">In Untersuchung</option>
|
||||
<option value="resolved">Geloest</option>
|
||||
<option value="closed">Geschlossen</option>
|
||||
</select>
|
||||
<span className="text-sm text-gray-500">
|
||||
{filteredEvents.length} Ereignisse
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form */}
|
||||
{showForm && (
|
||||
<EventForm onSubmit={handleSubmit} onCancel={() => setShowForm(false)} />
|
||||
)}
|
||||
|
||||
{/* Resolve Modal */}
|
||||
{resolvingEvent && (
|
||||
<ResolveModal
|
||||
event={resolvingEvent}
|
||||
onSubmit={handleResolve}
|
||||
onClose={() => setResolvingEvent(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Timeline */}
|
||||
{filteredEvents.length > 0 ? (
|
||||
<div className="pl-1">
|
||||
{filteredEvents
|
||||
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
|
||||
.map((event) => (
|
||||
<TimelineEvent
|
||||
key={event.id}
|
||||
event={event}
|
||||
onResolve={() => setResolvingEvent(event)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
!showForm && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-12 text-center">
|
||||
<div className="w-16 h-16 mx-auto bg-purple-100 dark:bg-purple-900/30 rounded-full flex items-center justify-center mb-4">
|
||||
<svg className="w-8 h-8 text-purple-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Keine Monitoring-Ereignisse</h3>
|
||||
<p className="mt-2 text-gray-500 max-w-md mx-auto">
|
||||
Erfassen Sie Vorfaelle, Software-Updates, KI-Drift-Warnungen und Regulierungsaenderungen
|
||||
im Rahmen der Post-Market Surveillance.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowForm(true)}
|
||||
className="mt-6 px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
Erstes Ereignis erfassen
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,483 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
|
||||
interface VerificationItem {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
method: string
|
||||
status: 'pending' | 'in_progress' | 'completed' | 'failed'
|
||||
result: string | null
|
||||
linked_hazard_id: string | null
|
||||
linked_hazard_name: string | null
|
||||
linked_mitigation_id: string | null
|
||||
linked_mitigation_name: string | null
|
||||
completed_at: string | null
|
||||
completed_by: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
const VERIFICATION_METHODS = [
|
||||
{ value: 'test', label: 'Test' },
|
||||
{ value: 'analysis', label: 'Analyse' },
|
||||
{ value: 'inspection', label: 'Inspektion' },
|
||||
{ value: 'simulation', label: 'Simulation' },
|
||||
{ value: 'review', label: 'Review' },
|
||||
{ value: 'demonstration', label: 'Demonstration' },
|
||||
{ value: 'certification', label: 'Zertifizierung' },
|
||||
]
|
||||
|
||||
const STATUS_CONFIG: Record<string, { label: string; color: string }> = {
|
||||
pending: { label: 'Ausstehend', color: 'bg-gray-100 text-gray-700' },
|
||||
in_progress: { label: 'In Bearbeitung', color: 'bg-blue-100 text-blue-700' },
|
||||
completed: { label: 'Abgeschlossen', color: 'bg-green-100 text-green-700' },
|
||||
failed: { label: 'Fehlgeschlagen', color: 'bg-red-100 text-red-700' },
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const config = STATUS_CONFIG[status] || STATUS_CONFIG.pending
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${config.color}`}>
|
||||
{config.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
interface VerificationFormData {
|
||||
title: string
|
||||
description: string
|
||||
method: string
|
||||
linked_hazard_id: string
|
||||
linked_mitigation_id: string
|
||||
}
|
||||
|
||||
function VerificationForm({
|
||||
onSubmit,
|
||||
onCancel,
|
||||
hazards,
|
||||
mitigations,
|
||||
}: {
|
||||
onSubmit: (data: VerificationFormData) => void
|
||||
onCancel: () => void
|
||||
hazards: { id: string; name: string }[]
|
||||
mitigations: { id: string; title: string }[]
|
||||
}) {
|
||||
const [formData, setFormData] = useState<VerificationFormData>({
|
||||
title: '',
|
||||
description: '',
|
||||
method: 'test',
|
||||
linked_hazard_id: '',
|
||||
linked_mitigation_id: '',
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">Neues Verifikationselement</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Titel *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||
placeholder="z.B. Funktionstest Lichtvorhang"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Methode</label>
|
||||
<select
|
||||
value={formData.method}
|
||||
onChange={(e) => setFormData({ ...formData, method: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
>
|
||||
{VERIFICATION_METHODS.map((m) => (
|
||||
<option key={m.value} value={m.value}>{m.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Beschreibung</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
rows={2}
|
||||
placeholder="Beschreiben Sie den Verifikationsschritt..."
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Verknuepfte Gefaehrdung</label>
|
||||
<select
|
||||
value={formData.linked_hazard_id}
|
||||
onChange={(e) => setFormData({ ...formData, linked_hazard_id: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
>
|
||||
<option value="">-- Keine --</option>
|
||||
{hazards.map((h) => (
|
||||
<option key={h.id} value={h.id}>{h.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Verknuepfte Massnahme</label>
|
||||
<select
|
||||
value={formData.linked_mitigation_id}
|
||||
onChange={(e) => setFormData({ ...formData, linked_mitigation_id: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
>
|
||||
<option value="">-- Keine --</option>
|
||||
{mitigations.map((m) => (
|
||||
<option key={m.id} value={m.id}>{m.title}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => onSubmit(formData)}
|
||||
disabled={!formData.title}
|
||||
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
|
||||
formData.title
|
||||
? 'bg-purple-600 text-white hover:bg-purple-700'
|
||||
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
Hinzufuegen
|
||||
</button>
|
||||
<button onClick={onCancel} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CompleteModal({
|
||||
item,
|
||||
onSubmit,
|
||||
onClose,
|
||||
}: {
|
||||
item: VerificationItem
|
||||
onSubmit: (id: string, result: string, passed: boolean) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [result, setResult] = useState('')
|
||||
const [passed, setPassed] = useState(true)
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl w-full max-w-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Verifikation abschliessen: {item.title}
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Ergebnis</label>
|
||||
<textarea
|
||||
value={result}
|
||||
onChange={(e) => setResult(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="Beschreiben Sie das Ergebnis der Verifikation..."
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Bewertung</label>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setPassed(true)}
|
||||
className={`flex-1 py-2 rounded-lg border text-sm font-medium transition-colors ${
|
||||
passed
|
||||
? 'border-green-400 bg-green-50 text-green-700'
|
||||
: 'border-gray-200 text-gray-500 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
Bestanden
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPassed(false)}
|
||||
className={`flex-1 py-2 rounded-lg border text-sm font-medium transition-colors ${
|
||||
!passed
|
||||
? 'border-red-400 bg-red-50 text-red-700'
|
||||
: 'border-gray-200 text-gray-500 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
Nicht bestanden
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => onSubmit(item.id, result, passed)}
|
||||
disabled={!result}
|
||||
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
|
||||
result
|
||||
? 'bg-purple-600 text-white hover:bg-purple-700'
|
||||
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
Abschliessen
|
||||
</button>
|
||||
<button onClick={onClose} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function VerificationPage() {
|
||||
const params = useParams()
|
||||
const projectId = params.projectId as string
|
||||
const [items, setItems] = useState<VerificationItem[]>([])
|
||||
const [hazards, setHazards] = useState<{ id: string; name: string }[]>([])
|
||||
const [mitigations, setMitigations] = useState<{ id: string; title: string }[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [completingItem, setCompletingItem] = useState<VerificationItem | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [projectId])
|
||||
|
||||
async function fetchData() {
|
||||
try {
|
||||
const [verRes, hazRes, mitRes] = await Promise.all([
|
||||
fetch(`/api/sdk/v1/iace/projects/${projectId}/verifications`),
|
||||
fetch(`/api/sdk/v1/iace/projects/${projectId}/hazards`),
|
||||
fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations`),
|
||||
])
|
||||
if (verRes.ok) {
|
||||
const json = await verRes.json()
|
||||
setItems(json.verifications || json || [])
|
||||
}
|
||||
if (hazRes.ok) {
|
||||
const json = await hazRes.json()
|
||||
setHazards((json.hazards || json || []).map((h: { id: string; name: string }) => ({ id: h.id, name: h.name })))
|
||||
}
|
||||
if (mitRes.ok) {
|
||||
const json = await mitRes.json()
|
||||
setMitigations((json.mitigations || json || []).map((m: { id: string; title: string }) => ({ id: m.id, title: m.title })))
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch data:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(data: VerificationFormData) {
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/verifications`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
if (res.ok) {
|
||||
setShowForm(false)
|
||||
await fetchData()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to add verification:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleComplete(id: string, result: string, passed: boolean) {
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/verifications/${id}/complete`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ result, passed }),
|
||||
})
|
||||
if (res.ok) {
|
||||
setCompletingItem(null)
|
||||
await fetchData()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to complete verification:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
if (!confirm('Verifikation wirklich loeschen?')) return
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/verifications/${id}`, { method: 'DELETE' })
|
||||
if (res.ok) {
|
||||
await fetchData()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to delete verification:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const completed = items.filter((i) => i.status === 'completed').length
|
||||
const failed = items.filter((i) => i.status === 'failed').length
|
||||
const pending = items.filter((i) => i.status === 'pending' || i.status === 'in_progress').length
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Verifikationsplan</h1>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Nachweisfuehrung fuer alle Schutzmassnahmen und Sicherheitsanforderungen.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowForm(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 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
Verifikation hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
{items.length > 0 && (
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4 text-center">
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">{items.length}</div>
|
||||
<div className="text-xs text-gray-500">Gesamt</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-green-200 p-4 text-center">
|
||||
<div className="text-2xl font-bold text-green-600">{completed}</div>
|
||||
<div className="text-xs text-green-600">Abgeschlossen</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-red-200 p-4 text-center">
|
||||
<div className="text-2xl font-bold text-red-600">{failed}</div>
|
||||
<div className="text-xs text-red-600">Fehlgeschlagen</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-yellow-200 p-4 text-center">
|
||||
<div className="text-2xl font-bold text-yellow-600">{pending}</div>
|
||||
<div className="text-xs text-yellow-600">Ausstehend</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form */}
|
||||
{showForm && (
|
||||
<VerificationForm
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={() => setShowForm(false)}
|
||||
hazards={hazards}
|
||||
mitigations={mitigations}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Complete Modal */}
|
||||
{completingItem && (
|
||||
<CompleteModal
|
||||
item={completingItem}
|
||||
onSubmit={handleComplete}
|
||||
onClose={() => setCompletingItem(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
{items.length > 0 ? (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 dark:bg-gray-750 border-b border-gray-200 dark:border-gray-700">
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Titel</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Methode</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Gefaehrdung</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Massnahme</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Ergebnis</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{items.map((item) => (
|
||||
<tr key={item.id} className="hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors">
|
||||
<td className="px-4 py-3">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">{item.title}</div>
|
||||
{item.description && (
|
||||
<div className="text-xs text-gray-500 truncate max-w-[200px]">{item.description}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300">
|
||||
{VERIFICATION_METHODS.find((m) => m.value === item.method)?.label || item.method}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600">{item.linked_hazard_name || '--'}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600">{item.linked_mitigation_name || '--'}</td>
|
||||
<td className="px-4 py-3"><StatusBadge status={item.status} /></td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600 max-w-[150px] truncate">{item.result || '--'}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{item.status !== 'completed' && item.status !== 'failed' && (
|
||||
<button
|
||||
onClick={() => setCompletingItem(item)}
|
||||
className="text-xs px-2.5 py-1 bg-green-50 text-green-700 border border-green-200 rounded-lg hover:bg-green-100 transition-colors"
|
||||
>
|
||||
Abschliessen
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleDelete(item.id)}
|
||||
className="p-1 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
!showForm && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-12 text-center">
|
||||
<div className="w-16 h-16 mx-auto bg-purple-100 dark:bg-purple-900/30 rounded-full flex items-center justify-center mb-4">
|
||||
<svg className="w-8 h-8 text-purple-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<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>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Kein Verifikationsplan vorhanden</h3>
|
||||
<p className="mt-2 text-gray-500 max-w-md mx-auto">
|
||||
Definieren Sie Verifikationsschritte fuer Ihre Schutzmassnahmen.
|
||||
Jede Massnahme sollte durch mindestens eine Verifikation abgedeckt sein.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowForm(true)}
|
||||
className="mt-6 px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
Erste Verifikation anlegen
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,573 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useSDK } from '@/lib/sdk'
|
||||
import type { ImportedDocument, ImportedDocumentType, GapAnalysis, GapItem } from '@/lib/sdk/types'
|
||||
|
||||
// =============================================================================
|
||||
// DOCUMENT TYPE OPTIONS
|
||||
// =============================================================================
|
||||
|
||||
const DOCUMENT_TYPES: { value: ImportedDocumentType; label: string; icon: string }[] = [
|
||||
{ value: 'DSFA', label: 'Datenschutz-Folgenabschaetzung (DSFA)', icon: '📄' },
|
||||
{ value: 'TOM', label: 'Technisch-organisatorische Massnahmen (TOMs)', icon: '🔒' },
|
||||
{ value: 'VVT', label: 'Verarbeitungsverzeichnis (VVT)', icon: '📊' },
|
||||
{ value: 'AGB', label: 'Allgemeine Geschaeftsbedingungen (AGB)', icon: '📜' },
|
||||
{ value: 'PRIVACY_POLICY', label: 'Datenschutzerklaerung', icon: '🔐' },
|
||||
{ value: 'COOKIE_POLICY', label: 'Cookie-Richtlinie', icon: '🍪' },
|
||||
{ value: 'RISK_ASSESSMENT', label: 'Risikobewertung', icon: '⚠️' },
|
||||
{ value: 'AUDIT_REPORT', label: 'Audit-Bericht', icon: '✅' },
|
||||
{ value: 'OTHER', label: 'Sonstiges Dokument', icon: '📎' },
|
||||
]
|
||||
|
||||
// =============================================================================
|
||||
// UPLOAD ZONE
|
||||
// =============================================================================
|
||||
|
||||
interface UploadedFile {
|
||||
id: string
|
||||
file: File
|
||||
type: ImportedDocumentType
|
||||
status: 'pending' | 'uploading' | 'analyzing' | 'complete' | 'error'
|
||||
progress: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
function UploadZone({
|
||||
onFilesAdded,
|
||||
isDisabled,
|
||||
}: {
|
||||
onFilesAdded: (files: File[]) => void
|
||||
isDisabled: boolean
|
||||
}) {
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
if (!isDisabled) setIsDragging(true)
|
||||
}, [isDisabled])
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
}, [])
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
if (isDisabled) return
|
||||
|
||||
const files = Array.from(e.dataTransfer.files).filter(
|
||||
f => f.type === 'application/pdf' || f.type.startsWith('image/')
|
||||
)
|
||||
if (files.length > 0) {
|
||||
onFilesAdded(files)
|
||||
}
|
||||
},
|
||||
[onFilesAdded, isDisabled]
|
||||
)
|
||||
|
||||
const handleFileSelect = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && !isDisabled) {
|
||||
const files = Array.from(e.target.files)
|
||||
onFilesAdded(files)
|
||||
}
|
||||
},
|
||||
[onFilesAdded, isDisabled]
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
className={`relative border-2 border-dashed rounded-xl p-12 text-center transition-all ${
|
||||
isDisabled
|
||||
? 'border-gray-200 bg-gray-50 cursor-not-allowed'
|
||||
: isDragging
|
||||
? 'border-purple-500 bg-purple-50'
|
||||
: 'border-gray-300 hover:border-purple-400 hover:bg-purple-50/50 cursor-pointer'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
accept=".pdf,image/*"
|
||||
multiple
|
||||
onChange={handleFileSelect}
|
||||
disabled={isDisabled}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed"
|
||||
/>
|
||||
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className={`w-16 h-16 rounded-full flex items-center justify-center ${isDragging ? 'bg-purple-100' : 'bg-gray-100'}`}>
|
||||
<svg
|
||||
className={`w-8 h-8 ${isDragging ? 'text-purple-600' : 'text-gray-400'}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-lg font-medium text-gray-900">
|
||||
{isDragging ? 'Dateien hier ablegen' : 'Dokumente hochladen'}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Ziehen Sie PDF-Dateien hierher oder klicken Sie zum Auswaehlen
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-xs text-gray-400">
|
||||
<span>Unterstuetzte Formate:</span>
|
||||
<span className="px-2 py-0.5 bg-gray-100 rounded">PDF</span>
|
||||
<span className="px-2 py-0.5 bg-gray-100 rounded">JPG</span>
|
||||
<span className="px-2 py-0.5 bg-gray-100 rounded">PNG</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// FILE LIST
|
||||
// =============================================================================
|
||||
|
||||
function FileItem({
|
||||
file,
|
||||
onTypeChange,
|
||||
onRemove,
|
||||
}: {
|
||||
file: UploadedFile
|
||||
onTypeChange: (id: string, type: ImportedDocumentType) => void
|
||||
onRemove: (id: string) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-4 p-4 bg-white rounded-xl border border-gray-200">
|
||||
{/* File Icon */}
|
||||
<div className="w-12 h-12 bg-gray-100 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<svg className="w-6 h-6 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* File Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-gray-900 truncate">{file.file.name}</p>
|
||||
<p className="text-sm text-gray-500">{(file.file.size / 1024 / 1024).toFixed(2)} MB</p>
|
||||
</div>
|
||||
|
||||
{/* Type Selector */}
|
||||
<select
|
||||
value={file.type}
|
||||
onChange={e => onTypeChange(file.id, e.target.value as ImportedDocumentType)}
|
||||
disabled={file.status !== 'pending'}
|
||||
className="px-3 py-2 bg-gray-50 border border-gray-200 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500 disabled:opacity-50"
|
||||
>
|
||||
{DOCUMENT_TYPES.map(dt => (
|
||||
<option key={dt.value} value={dt.value}>
|
||||
{dt.icon} {dt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Status / Actions */}
|
||||
{file.status === 'pending' && (
|
||||
<button
|
||||
onClick={() => onRemove(file.id)}
|
||||
className="p-2 text-gray-400 hover:text-red-500 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>
|
||||
)}
|
||||
{file.status === 'uploading' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-20 h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-purple-600 rounded-full transition-all"
|
||||
style={{ width: `${file.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-gray-500">{file.progress}%</span>
|
||||
</div>
|
||||
)}
|
||||
{file.status === 'analyzing' && (
|
||||
<div className="flex items-center gap-2 text-purple-600">
|
||||
<svg className="w-5 h-5 animate-spin" 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>
|
||||
<span className="text-sm">Analysiere...</span>
|
||||
</div>
|
||||
)}
|
||||
{file.status === 'complete' && (
|
||||
<div className="flex items-center gap-1 text-green-600">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span className="text-sm">Fertig</span>
|
||||
</div>
|
||||
)}
|
||||
{file.status === 'error' && (
|
||||
<div className="flex items-center gap-1 text-red-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>
|
||||
<span className="text-sm">{file.error || 'Fehler'}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// GAP ANALYSIS PREVIEW
|
||||
// =============================================================================
|
||||
|
||||
function GapAnalysisPreview({ analysis }: { analysis: GapAnalysis }) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-12 h-12 bg-orange-100 rounded-xl flex items-center justify-center">
|
||||
<span className="text-2xl">📊</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Gap-Analyse Ergebnis</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{analysis.totalGaps} Luecken in {analysis.gaps.length} Kategorien gefunden
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary Stats */}
|
||||
<div className="grid grid-cols-4 gap-4 mb-6">
|
||||
<div className="text-center p-4 bg-red-50 rounded-xl">
|
||||
<div className="text-3xl font-bold text-red-600">{analysis.criticalGaps}</div>
|
||||
<div className="text-sm text-red-600 font-medium">Kritisch</div>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-orange-50 rounded-xl">
|
||||
<div className="text-3xl font-bold text-orange-600">{analysis.highGaps}</div>
|
||||
<div className="text-sm text-orange-600 font-medium">Hoch</div>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-yellow-50 rounded-xl">
|
||||
<div className="text-3xl font-bold text-yellow-600">{analysis.mediumGaps}</div>
|
||||
<div className="text-sm text-yellow-600 font-medium">Mittel</div>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-green-50 rounded-xl">
|
||||
<div className="text-3xl font-bold text-green-600">{analysis.lowGaps}</div>
|
||||
<div className="text-sm text-green-600 font-medium">Niedrig</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Gap List */}
|
||||
<div className="space-y-3">
|
||||
{analysis.gaps.slice(0, 5).map((gap: GapItem) => (
|
||||
<div
|
||||
key={gap.id}
|
||||
className={`p-4 rounded-lg border-l-4 ${
|
||||
gap.severity === 'CRITICAL'
|
||||
? 'bg-red-50 border-red-500'
|
||||
: gap.severity === 'HIGH'
|
||||
? 'bg-orange-50 border-orange-500'
|
||||
: gap.severity === 'MEDIUM'
|
||||
? 'bg-yellow-50 border-yellow-500'
|
||||
: 'bg-green-50 border-green-500'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{gap.category}</div>
|
||||
<p className="text-sm text-gray-600 mt-1">{gap.description}</p>
|
||||
</div>
|
||||
<span
|
||||
className={`px-2 py-1 text-xs font-medium rounded ${
|
||||
gap.severity === 'CRITICAL'
|
||||
? 'bg-red-100 text-red-700'
|
||||
: gap.severity === 'HIGH'
|
||||
? 'bg-orange-100 text-orange-700'
|
||||
: gap.severity === 'MEDIUM'
|
||||
? 'bg-yellow-100 text-yellow-700'
|
||||
: 'bg-green-100 text-green-700'
|
||||
}`}
|
||||
>
|
||||
{gap.severity}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-gray-500">
|
||||
Regulierung: {gap.regulation} | Aktion: {gap.requiredAction}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{analysis.gaps.length > 5 && (
|
||||
<p className="text-sm text-gray-500 text-center py-2">
|
||||
+ {analysis.gaps.length - 5} weitere Luecken
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN PAGE
|
||||
// =============================================================================
|
||||
|
||||
export default function ImportPage() {
|
||||
const router = useRouter()
|
||||
const { state, addImportedDocument, setGapAnalysis, dispatch } = useSDK()
|
||||
const [files, setFiles] = useState<UploadedFile[]>([])
|
||||
const [isAnalyzing, setIsAnalyzing] = useState(false)
|
||||
const [analysisResult, setAnalysisResult] = useState<GapAnalysis | null>(null)
|
||||
|
||||
const handleFilesAdded = useCallback((newFiles: File[]) => {
|
||||
const uploadedFiles: UploadedFile[] = newFiles.map(file => ({
|
||||
id: `file-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||
file,
|
||||
type: 'OTHER' as ImportedDocumentType,
|
||||
status: 'pending' as const,
|
||||
progress: 0,
|
||||
}))
|
||||
setFiles(prev => [...prev, ...uploadedFiles])
|
||||
}, [])
|
||||
|
||||
const handleTypeChange = useCallback((id: string, type: ImportedDocumentType) => {
|
||||
setFiles(prev => prev.map(f => (f.id === id ? { ...f, type } : f)))
|
||||
}, [])
|
||||
|
||||
const handleRemove = useCallback((id: string) => {
|
||||
setFiles(prev => prev.filter(f => f.id !== id))
|
||||
}, [])
|
||||
|
||||
const handleAnalyze = async () => {
|
||||
if (files.length === 0) return
|
||||
|
||||
setIsAnalyzing(true)
|
||||
|
||||
// Simulate upload and analysis
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i]
|
||||
|
||||
// Update to uploading
|
||||
setFiles(prev => prev.map(f => (f.id === file.id ? { ...f, status: 'uploading' as const } : f)))
|
||||
|
||||
// Simulate upload progress
|
||||
for (let p = 0; p <= 100; p += 20) {
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
setFiles(prev => prev.map(f => (f.id === file.id ? { ...f, progress: p } : f)))
|
||||
}
|
||||
|
||||
// Update to analyzing
|
||||
setFiles(prev => prev.map(f => (f.id === file.id ? { ...f, status: 'analyzing' as const } : f)))
|
||||
|
||||
// Simulate analysis
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
|
||||
// Create imported document
|
||||
const doc: ImportedDocument = {
|
||||
id: file.id,
|
||||
name: file.file.name,
|
||||
type: file.type,
|
||||
fileUrl: URL.createObjectURL(file.file),
|
||||
uploadedAt: new Date(),
|
||||
analyzedAt: new Date(),
|
||||
analysisResult: {
|
||||
detectedType: file.type,
|
||||
confidence: 0.85 + Math.random() * 0.15,
|
||||
extractedEntities: ['DSGVO', 'AI Act', 'Personenbezogene Daten'],
|
||||
gaps: [],
|
||||
recommendations: ['KI-spezifische Klauseln ergaenzen', 'AI Act Anforderungen pruefen'],
|
||||
},
|
||||
}
|
||||
|
||||
addImportedDocument(doc)
|
||||
|
||||
// Update to complete
|
||||
setFiles(prev => prev.map(f => (f.id === file.id ? { ...f, status: 'complete' as const } : f)))
|
||||
}
|
||||
|
||||
// Generate mock gap analysis
|
||||
const gaps: GapItem[] = [
|
||||
{
|
||||
id: 'gap-1',
|
||||
category: 'AI Act Compliance',
|
||||
description: 'Keine Risikoklassifizierung fuer KI-Systeme vorhanden',
|
||||
severity: 'CRITICAL',
|
||||
regulation: 'EU AI Act Art. 6',
|
||||
requiredAction: 'Risikoklassifizierung durchfuehren',
|
||||
relatedStepId: 'ai-act',
|
||||
},
|
||||
{
|
||||
id: 'gap-2',
|
||||
category: 'Transparenz',
|
||||
description: 'Informationspflichten bei automatisierten Entscheidungen fehlen',
|
||||
severity: 'HIGH',
|
||||
regulation: 'DSGVO Art. 13, 14, 22',
|
||||
requiredAction: 'Datenschutzerklaerung erweitern',
|
||||
relatedStepId: 'einwilligungen',
|
||||
},
|
||||
{
|
||||
id: 'gap-3',
|
||||
category: 'TOMs',
|
||||
description: 'KI-spezifische technische Massnahmen nicht dokumentiert',
|
||||
severity: 'MEDIUM',
|
||||
regulation: 'DSGVO Art. 32',
|
||||
requiredAction: 'TOMs um KI-Aspekte erweitern',
|
||||
relatedStepId: 'tom',
|
||||
},
|
||||
{
|
||||
id: 'gap-4',
|
||||
category: 'VVT',
|
||||
description: 'KI-basierte Verarbeitungstaetigkeiten nicht erfasst',
|
||||
severity: 'HIGH',
|
||||
regulation: 'DSGVO Art. 30',
|
||||
requiredAction: 'VVT aktualisieren',
|
||||
relatedStepId: 'vvt',
|
||||
},
|
||||
{
|
||||
id: 'gap-5',
|
||||
category: 'Aufsicht',
|
||||
description: 'Menschliche Aufsicht nicht definiert',
|
||||
severity: 'MEDIUM',
|
||||
regulation: 'EU AI Act Art. 14',
|
||||
requiredAction: 'Aufsichtsprozesse definieren',
|
||||
relatedStepId: 'controls',
|
||||
},
|
||||
]
|
||||
|
||||
const gapAnalysis: GapAnalysis = {
|
||||
id: `analysis-${Date.now()}`,
|
||||
createdAt: new Date(),
|
||||
totalGaps: gaps.length,
|
||||
criticalGaps: gaps.filter(g => g.severity === 'CRITICAL').length,
|
||||
highGaps: gaps.filter(g => g.severity === 'HIGH').length,
|
||||
mediumGaps: gaps.filter(g => g.severity === 'MEDIUM').length,
|
||||
lowGaps: gaps.filter(g => g.severity === 'LOW').length,
|
||||
gaps,
|
||||
recommendedPackages: ['analyse', 'dokumentation'],
|
||||
}
|
||||
|
||||
setAnalysisResult(gapAnalysis)
|
||||
setGapAnalysis(gapAnalysis)
|
||||
setIsAnalyzing(false)
|
||||
|
||||
// Mark step as complete
|
||||
dispatch({ type: 'COMPLETE_STEP', payload: 'import' })
|
||||
}
|
||||
|
||||
const handleContinue = () => {
|
||||
router.push('/sdk/screening')
|
||||
}
|
||||
|
||||
// Redirect if not existing customer
|
||||
if (state.customerType === 'new') {
|
||||
router.push('/sdk')
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-8">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Dokumente importieren</h1>
|
||||
<p className="mt-1 text-gray-500">
|
||||
Laden Sie Ihre bestehenden Compliance-Dokumente hoch. Unsere KI analysiert sie und identifiziert Luecken fuer KI-Compliance.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Upload Zone */}
|
||||
<UploadZone onFilesAdded={handleFilesAdded} isDisabled={isAnalyzing} />
|
||||
|
||||
{/* File List */}
|
||||
{files.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-semibold text-gray-900">{files.length} Dokument(e)</h2>
|
||||
{!isAnalyzing && !analysisResult && (
|
||||
<button
|
||||
onClick={() => setFiles([])}
|
||||
className="text-sm text-gray-500 hover:text-red-500"
|
||||
>
|
||||
Alle entfernen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{files.map(file => (
|
||||
<FileItem
|
||||
key={file.id}
|
||||
file={file}
|
||||
onTypeChange={handleTypeChange}
|
||||
onRemove={handleRemove}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Analyze Button */}
|
||||
{files.length > 0 && !analysisResult && (
|
||||
<div className="flex justify-center">
|
||||
<button
|
||||
onClick={handleAnalyze}
|
||||
disabled={isAnalyzing}
|
||||
className="px-8 py-3 bg-gradient-to-r from-purple-600 to-indigo-600 text-white font-medium rounded-xl hover:from-purple-700 hover:to-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all flex items-center gap-2"
|
||||
>
|
||||
{isAnalyzing ? (
|
||||
<>
|
||||
<svg className="w-5 h-5 animate-spin" 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>
|
||||
Analysiere Dokumente...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
Gap-Analyse starten
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Analysis Result */}
|
||||
{analysisResult && <GapAnalysisPreview analysis={analysisResult} />}
|
||||
|
||||
{/* Continue Button */}
|
||||
{analysisResult && (
|
||||
<div className="flex justify-between items-center pt-4 border-t border-gray-200">
|
||||
<p className="text-sm text-gray-500">
|
||||
Die Gap-Analyse wurde gespeichert. Sie koennen jetzt mit dem Compliance-Assessment fortfahren.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleContinue}
|
||||
className="px-6 py-2.5 bg-green-600 text-white font-medium rounded-lg hover:bg-green-700 transition-colors flex items-center gap-2"
|
||||
>
|
||||
Weiter zum Screening
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7l5 5m0 0l-5 5m5-5H6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,879 +0,0 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Branchenspezifische Module (Phase 3.3)
|
||||
*
|
||||
* Industry-specific compliance template packages:
|
||||
* - Browse industry templates (grid view)
|
||||
* - View full detail with VVT, TOM, Risk tabs
|
||||
* - Apply template packages to current compliance setup
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
interface IndustrySummary {
|
||||
slug: string
|
||||
name: string
|
||||
description: string
|
||||
icon: string
|
||||
regulation_count: number
|
||||
template_count: number
|
||||
}
|
||||
|
||||
interface IndustryTemplate {
|
||||
slug: string
|
||||
name: string
|
||||
description: string
|
||||
icon: string
|
||||
regulations: string[]
|
||||
vvt_templates: VVTTemplate[]
|
||||
tom_recommendations: TOMRecommendation[]
|
||||
risk_scenarios: RiskScenario[]
|
||||
}
|
||||
|
||||
interface VVTTemplate {
|
||||
name: string
|
||||
purpose: string
|
||||
legal_basis: string
|
||||
data_categories: string[]
|
||||
data_subjects: string[]
|
||||
retention_period: string
|
||||
}
|
||||
|
||||
interface TOMRecommendation {
|
||||
category: string
|
||||
name: string
|
||||
description: string
|
||||
priority: string
|
||||
}
|
||||
|
||||
interface RiskScenario {
|
||||
name: string
|
||||
description: string
|
||||
likelihood: string
|
||||
impact: string
|
||||
mitigation: string
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CONSTANTS
|
||||
// =============================================================================
|
||||
|
||||
type DetailTab = 'vvt' | 'tom' | 'risks'
|
||||
|
||||
const DETAIL_TABS: { key: DetailTab; label: string }[] = [
|
||||
{ key: 'vvt', label: 'VVT-Vorlagen' },
|
||||
{ key: 'tom', label: 'TOM-Empfehlungen' },
|
||||
{ key: 'risks', label: 'Risiko-Szenarien' },
|
||||
]
|
||||
|
||||
const PRIORITY_COLORS: Record<string, { bg: string; text: string; border: string }> = {
|
||||
critical: { bg: 'bg-red-50', text: 'text-red-700', border: 'border-red-200' },
|
||||
high: { bg: 'bg-orange-50', text: 'text-orange-700', border: 'border-orange-200' },
|
||||
medium: { bg: 'bg-yellow-50', text: 'text-yellow-700', border: 'border-yellow-200' },
|
||||
low: { bg: 'bg-green-50', text: 'text-green-700', border: 'border-green-200' },
|
||||
}
|
||||
|
||||
const PRIORITY_LABELS: Record<string, string> = {
|
||||
critical: 'Kritisch',
|
||||
high: 'Hoch',
|
||||
medium: 'Mittel',
|
||||
low: 'Niedrig',
|
||||
}
|
||||
|
||||
const LIKELIHOOD_COLORS: Record<string, string> = {
|
||||
low: 'bg-green-500',
|
||||
medium: 'bg-yellow-500',
|
||||
high: 'bg-orange-500',
|
||||
}
|
||||
|
||||
const IMPACT_COLORS: Record<string, string> = {
|
||||
low: 'bg-green-500',
|
||||
medium: 'bg-yellow-500',
|
||||
high: 'bg-orange-500',
|
||||
critical: 'bg-red-600',
|
||||
}
|
||||
|
||||
const TOM_CATEGORY_ICONS: Record<string, string> = {
|
||||
'Zutrittskontrolle': '\uD83D\uDEAA',
|
||||
'Zugangskontrolle': '\uD83D\uDD10',
|
||||
'Zugriffskontrolle': '\uD83D\uDC65',
|
||||
'Trennungskontrolle': '\uD83D\uDDC2\uFE0F',
|
||||
'Pseudonymisierung': '\uD83C\uDFAD',
|
||||
'Verschluesselung': '\uD83D\uDD12',
|
||||
'Integritaet': '\u2705',
|
||||
'Verfuegbarkeit': '\u2B06\uFE0F',
|
||||
'Belastbarkeit': '\uD83D\uDEE1\uFE0F',
|
||||
'Wiederherstellung': '\uD83D\uDD04',
|
||||
'Datenschutz-Management': '\uD83D\uDCCB',
|
||||
'Auftragsverarbeitung': '\uD83D\uDCDD',
|
||||
'Incident Response': '\uD83D\uDEA8',
|
||||
'Schulung': '\uD83C\uDF93',
|
||||
'Netzwerksicherheit': '\uD83C\uDF10',
|
||||
'Datensicherung': '\uD83D\uDCBE',
|
||||
'Monitoring': '\uD83D\uDCCA',
|
||||
'Physische Sicherheit': '\uD83C\uDFE2',
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// SKELETON COMPONENTS
|
||||
// =============================================================================
|
||||
|
||||
function GridSkeleton() {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="bg-white rounded-xl border border-slate-200 p-6 animate-pulse">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-14 h-14 rounded-xl bg-slate-200" />
|
||||
<div className="flex-1 space-y-3">
|
||||
<div className="h-5 bg-slate-200 rounded w-2/3" />
|
||||
<div className="h-4 bg-slate-100 rounded w-full" />
|
||||
<div className="h-4 bg-slate-100 rounded w-4/5" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3 mt-5">
|
||||
<div className="h-6 bg-slate-100 rounded-full w-28" />
|
||||
<div className="h-6 bg-slate-100 rounded-full w-24" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DetailSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6 animate-pulse">
|
||||
<div className="bg-white rounded-xl border p-6 space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-16 h-16 rounded-xl bg-slate-200" />
|
||||
<div className="space-y-2 flex-1">
|
||||
<div className="h-6 bg-slate-200 rounded w-1/3" />
|
||||
<div className="h-4 bg-slate-100 rounded w-2/3" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="h-7 bg-slate-100 rounded-full w-20" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border p-6 space-y-4">
|
||||
<div className="flex gap-2 border-b pb-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="h-9 bg-slate-100 rounded-lg w-32" />
|
||||
))}
|
||||
</div>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="h-28 bg-slate-50 rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN PAGE COMPONENT
|
||||
// =============================================================================
|
||||
|
||||
export default function IndustryTemplatesPage() {
|
||||
// ---------------------------------------------------------------------------
|
||||
// State
|
||||
// ---------------------------------------------------------------------------
|
||||
const [industries, setIndustries] = useState<IndustrySummary[]>([])
|
||||
const [selectedDetail, setSelectedDetail] = useState<IndustryTemplate | null>(null)
|
||||
const [selectedSlug, setSelectedSlug] = useState<string | null>(null)
|
||||
const [activeTab, setActiveTab] = useState<DetailTab>('vvt')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [detailLoading, setDetailLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [detailError, setDetailError] = useState<string | null>(null)
|
||||
const [applying, setApplying] = useState(false)
|
||||
const [toastMessage, setToastMessage] = useState<string | null>(null)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Data fetching
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const loadIndustries = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch('/api/sdk/v1/industry/templates')
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}: ${res.statusText}`)
|
||||
}
|
||||
const data = await res.json()
|
||||
setIndustries(Array.isArray(data) ? data : data.industries || data.templates || [])
|
||||
} catch (err) {
|
||||
console.error('Failed to load industries:', err)
|
||||
setError('Branchenvorlagen konnten nicht geladen werden. Bitte pruefen Sie die Verbindung zum Backend.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const loadDetail = useCallback(async (slug: string) => {
|
||||
setDetailLoading(true)
|
||||
setDetailError(null)
|
||||
setSelectedSlug(slug)
|
||||
setActiveTab('vvt')
|
||||
try {
|
||||
const [detailRes, vvtRes, tomRes, risksRes] = await Promise.all([
|
||||
fetch(`/api/sdk/v1/industry/templates/${slug}`),
|
||||
fetch(`/api/sdk/v1/industry/templates/${slug}/vvt`),
|
||||
fetch(`/api/sdk/v1/industry/templates/${slug}/tom`),
|
||||
fetch(`/api/sdk/v1/industry/templates/${slug}/risks`),
|
||||
])
|
||||
|
||||
if (!detailRes.ok) {
|
||||
throw new Error(`HTTP ${detailRes.status}: ${detailRes.statusText}`)
|
||||
}
|
||||
|
||||
const detail: IndustryTemplate = await detailRes.json()
|
||||
|
||||
// Merge sub-resources if the detail endpoint did not include them
|
||||
if (vvtRes.ok) {
|
||||
const vvtData = await vvtRes.json()
|
||||
detail.vvt_templates = vvtData.vvt_templates || vvtData.templates || vvtData || []
|
||||
}
|
||||
if (tomRes.ok) {
|
||||
const tomData = await tomRes.json()
|
||||
detail.tom_recommendations = tomData.tom_recommendations || tomData.recommendations || tomData || []
|
||||
}
|
||||
if (risksRes.ok) {
|
||||
const risksData = await risksRes.json()
|
||||
detail.risk_scenarios = risksData.risk_scenarios || risksData.scenarios || risksData || []
|
||||
}
|
||||
|
||||
setSelectedDetail(detail)
|
||||
} catch (err) {
|
||||
console.error('Failed to load industry detail:', err)
|
||||
setDetailError('Details konnten nicht geladen werden. Bitte versuchen Sie es erneut.')
|
||||
} finally {
|
||||
setDetailLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadIndustries()
|
||||
}, [loadIndustries])
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const handleBackToGrid = useCallback(() => {
|
||||
setSelectedSlug(null)
|
||||
setSelectedDetail(null)
|
||||
setDetailError(null)
|
||||
}, [])
|
||||
|
||||
const handleApplyPackage = useCallback(async () => {
|
||||
if (!selectedDetail) return
|
||||
setApplying(true)
|
||||
try {
|
||||
// Placeholder: In production this would POST to an import endpoint
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500))
|
||||
setToastMessage(
|
||||
`Branchenpaket "${selectedDetail.name}" wurde erfolgreich angewendet. ` +
|
||||
`${selectedDetail.vvt_templates?.length || 0} VVT-Vorlagen, ` +
|
||||
`${selectedDetail.tom_recommendations?.length || 0} TOM-Empfehlungen und ` +
|
||||
`${selectedDetail.risk_scenarios?.length || 0} Risiko-Szenarien wurden importiert.`
|
||||
)
|
||||
} catch {
|
||||
setToastMessage('Fehler beim Anwenden des Branchenpakets. Bitte versuchen Sie es erneut.')
|
||||
} finally {
|
||||
setApplying(false)
|
||||
}
|
||||
}, [selectedDetail])
|
||||
|
||||
// Auto-dismiss toast
|
||||
useEffect(() => {
|
||||
if (!toastMessage) return
|
||||
const timer = setTimeout(() => setToastMessage(null), 6000)
|
||||
return () => clearTimeout(timer)
|
||||
}, [toastMessage])
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render: Header
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const renderHeader = () => (
|
||||
<div className="bg-white rounded-xl shadow-sm border border-slate-200 p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-emerald-500 to-teal-600 flex items-center justify-center text-white text-lg">
|
||||
{'\uD83C\uDFED'}
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">Branchenspezifische Module</h1>
|
||||
<p className="text-slate-500 mt-0.5">
|
||||
Vorkonfigurierte Compliance-Pakete nach Branche
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render: Error
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const renderError = (message: string, onRetry: () => void) => (
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl p-5 flex items-start gap-3">
|
||||
<svg className="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div className="flex-1">
|
||||
<p className="text-red-700 font-medium">Fehler</p>
|
||||
<p className="text-red-600 text-sm mt-1">{message}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onRetry}
|
||||
className="px-4 py-1.5 text-sm font-medium text-red-700 bg-red-100 hover:bg-red-200 rounded-lg transition-colors"
|
||||
>
|
||||
Erneut versuchen
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render: Industry Grid
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const renderGrid = () => (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{industries.map((industry) => (
|
||||
<button
|
||||
key={industry.slug}
|
||||
onClick={() => loadDetail(industry.slug)}
|
||||
className="bg-white rounded-xl border border-slate-200 p-6 text-left hover:border-emerald-300 hover:shadow-md transition-all duration-200 group"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-14 h-14 rounded-xl bg-gradient-to-br from-emerald-50 to-teal-50 border border-emerald-100 flex items-center justify-center text-3xl flex-shrink-0 group-hover:from-emerald-100 group-hover:to-teal-100 transition-colors">
|
||||
{industry.icon}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-lg font-semibold text-slate-900 group-hover:text-emerald-700 transition-colors">
|
||||
{industry.name}
|
||||
</h3>
|
||||
<p className="text-sm text-slate-500 mt-1 line-clamp-2">
|
||||
{industry.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mt-4">
|
||||
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium bg-emerald-50 text-emerald-700 border border-emerald-100">
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
{industry.regulation_count} Regulierungen
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium bg-teal-50 text-teal-700 border border-teal-100">
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z" />
|
||||
</svg>
|
||||
{industry.template_count} Vorlagen
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render: Detail View - Header
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const renderDetailHeader = () => {
|
||||
if (!selectedDetail) return null
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<button
|
||||
onClick={handleBackToGrid}
|
||||
className="inline-flex items-center gap-1.5 text-sm text-slate-500 hover:text-emerald-600 transition-colors mb-4"
|
||||
>
|
||||
<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>
|
||||
Zurueck zur Uebersicht
|
||||
</button>
|
||||
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-16 h-16 rounded-xl bg-gradient-to-br from-emerald-50 to-teal-50 border border-emerald-100 flex items-center justify-center text-4xl flex-shrink-0">
|
||||
{selectedDetail.icon}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className="text-xl font-bold text-slate-900">{selectedDetail.name}</h2>
|
||||
<p className="text-slate-500 mt-1">{selectedDetail.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Regulation Badges */}
|
||||
{selectedDetail.regulations && selectedDetail.regulations.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<p className="text-xs font-medium text-slate-400 uppercase tracking-wider mb-2">
|
||||
Relevante Regulierungen
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedDetail.regulations.map((reg) => (
|
||||
<span
|
||||
key={reg}
|
||||
className="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-emerald-50 text-emerald-700 border border-emerald-200"
|
||||
>
|
||||
{reg}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary stats */}
|
||||
<div className="grid grid-cols-3 gap-4 mt-5 pt-5 border-t border-slate-100">
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold text-emerald-600">
|
||||
{selectedDetail.vvt_templates?.length || 0}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 mt-0.5">VVT-Vorlagen</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold text-teal-600">
|
||||
{selectedDetail.tom_recommendations?.length || 0}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 mt-0.5">TOM-Empfehlungen</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold text-amber-600">
|
||||
{selectedDetail.risk_scenarios?.length || 0}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 mt-0.5">Risiko-Szenarien</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render: VVT Tab
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const renderVVTTab = () => {
|
||||
const templates = selectedDetail?.vvt_templates || []
|
||||
if (templates.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12 text-slate-400">
|
||||
<p className="text-lg">Keine VVT-Vorlagen verfuegbar</p>
|
||||
<p className="text-sm mt-1">Fuer diese Branche wurden noch keine Verarbeitungsvorlagen definiert.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{templates.map((vvt, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="bg-white border border-slate-200 rounded-lg p-5 hover:border-emerald-200 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<h4 className="font-semibold text-slate-900">{vvt.name}</h4>
|
||||
<p className="text-sm text-slate-500 mt-1">{vvt.purpose}</p>
|
||||
</div>
|
||||
<span className="inline-flex items-center px-2.5 py-1 rounded-md text-xs font-medium bg-slate-100 text-slate-600 flex-shrink-0">
|
||||
{vvt.retention_period}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 pt-3 border-t border-slate-100">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{/* Legal Basis */}
|
||||
<div>
|
||||
<p className="text-xs font-medium text-slate-400 uppercase tracking-wider mb-1">Rechtsgrundlage</p>
|
||||
<p className="text-sm text-slate-700">{vvt.legal_basis}</p>
|
||||
</div>
|
||||
|
||||
{/* Retention Period (mobile only, since shown in badge on desktop) */}
|
||||
<div className="sm:hidden">
|
||||
<p className="text-xs font-medium text-slate-400 uppercase tracking-wider mb-1">Aufbewahrungsfrist</p>
|
||||
<p className="text-sm text-slate-700">{vvt.retention_period}</p>
|
||||
</div>
|
||||
|
||||
{/* Data Categories */}
|
||||
<div>
|
||||
<p className="text-xs font-medium text-slate-400 uppercase tracking-wider mb-1.5">Datenkategorien</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{vvt.data_categories.map((cat) => (
|
||||
<span
|
||||
key={cat}
|
||||
className="inline-flex items-center px-2 py-0.5 rounded text-xs bg-emerald-50 text-emerald-700 border border-emerald-100"
|
||||
>
|
||||
{cat}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Data Subjects */}
|
||||
<div>
|
||||
<p className="text-xs font-medium text-slate-400 uppercase tracking-wider mb-1.5">Betroffene</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{vvt.data_subjects.map((sub) => (
|
||||
<span
|
||||
key={sub}
|
||||
className="inline-flex items-center px-2 py-0.5 rounded text-xs bg-teal-50 text-teal-700 border border-teal-100"
|
||||
>
|
||||
{sub}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render: TOM Tab
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const renderTOMTab = () => {
|
||||
const recommendations = selectedDetail?.tom_recommendations || []
|
||||
if (recommendations.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12 text-slate-400">
|
||||
<p className="text-lg">Keine TOM-Empfehlungen verfuegbar</p>
|
||||
<p className="text-sm mt-1">Fuer diese Branche wurden noch keine technisch-organisatorischen Massnahmen definiert.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Group by category
|
||||
const grouped: Record<string, TOMRecommendation[]> = {}
|
||||
recommendations.forEach((tom) => {
|
||||
if (!grouped[tom.category]) {
|
||||
grouped[tom.category] = []
|
||||
}
|
||||
grouped[tom.category].push(tom)
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{Object.entries(grouped).map(([category, items]) => {
|
||||
const icon = TOM_CATEGORY_ICONS[category] || '\uD83D\uDD27'
|
||||
return (
|
||||
<div key={category}>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="text-lg">{icon}</span>
|
||||
<h4 className="font-semibold text-slate-800">{category}</h4>
|
||||
<span className="text-xs text-slate-400 ml-1">({items.length})</span>
|
||||
</div>
|
||||
<div className="space-y-3 ml-7">
|
||||
{items.map((tom, idx) => {
|
||||
const prio = PRIORITY_COLORS[tom.priority] || PRIORITY_COLORS.medium
|
||||
const prioLabel = PRIORITY_LABELS[tom.priority] || tom.priority
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className="bg-white border border-slate-200 rounded-lg p-4 hover:border-emerald-200 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1">
|
||||
<h5 className="font-medium text-slate-900">{tom.name}</h5>
|
||||
<p className="text-sm text-slate-500 mt-1">{tom.description}</p>
|
||||
</div>
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold border flex-shrink-0 ${prio.bg} ${prio.text} ${prio.border}`}
|
||||
>
|
||||
{prioLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render: Risk Tab
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const renderRiskTab = () => {
|
||||
const scenarios = selectedDetail?.risk_scenarios || []
|
||||
if (scenarios.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12 text-slate-400">
|
||||
<p className="text-lg">Keine Risiko-Szenarien verfuegbar</p>
|
||||
<p className="text-sm mt-1">Fuer diese Branche wurden noch keine Risiko-Szenarien definiert.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{scenarios.map((risk, idx) => {
|
||||
const likelihoodColor = LIKELIHOOD_COLORS[risk.likelihood] || 'bg-slate-400'
|
||||
const impactColor = IMPACT_COLORS[risk.impact] || 'bg-slate-400'
|
||||
const likelihoodLabel = PRIORITY_LABELS[risk.likelihood] || risk.likelihood
|
||||
const impactLabel = PRIORITY_LABELS[risk.impact] || risk.impact
|
||||
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className="bg-white border border-slate-200 rounded-lg p-5 hover:border-emerald-200 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<h4 className="font-semibold text-slate-900">{risk.name}</h4>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{/* Likelihood badge */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`w-2.5 h-2.5 rounded-full ${likelihoodColor}`} />
|
||||
<span className="text-xs text-slate-500">
|
||||
Wahrsch.: <span className="font-medium text-slate-700">{likelihoodLabel}</span>
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-slate-300">|</span>
|
||||
{/* Impact badge */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`w-2.5 h-2.5 rounded-full ${impactColor}`} />
|
||||
<span className="text-xs text-slate-500">
|
||||
Auswirkung: <span className="font-medium text-slate-700">{impactLabel}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-slate-500 mt-2">{risk.description}</p>
|
||||
|
||||
{/* Mitigation */}
|
||||
<div className="mt-3 pt-3 border-t border-slate-100">
|
||||
<div className="flex items-start gap-2">
|
||||
<svg className="w-4 h-4 text-emerald-500 flex-shrink-0 mt-0.5" 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>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-slate-400 uppercase tracking-wider">Massnahme</p>
|
||||
<p className="text-sm text-slate-700 mt-0.5">{risk.mitigation}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render: Detail Tabs + Content
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const renderDetailContent = () => {
|
||||
if (!selectedDetail) return null
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
{/* Tab Navigation */}
|
||||
<div className="flex border-b border-slate-200 bg-slate-50">
|
||||
{DETAIL_TABS.map((tab) => {
|
||||
const isActive = activeTab === tab.key
|
||||
let count = 0
|
||||
if (tab.key === 'vvt') count = selectedDetail.vvt_templates?.length || 0
|
||||
if (tab.key === 'tom') count = selectedDetail.tom_recommendations?.length || 0
|
||||
if (tab.key === 'risks') count = selectedDetail.risk_scenarios?.length || 0
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={`flex-1 px-4 py-3 text-sm font-medium transition-colors relative ${
|
||||
isActive
|
||||
? 'text-emerald-700 bg-white border-b-2 border-emerald-500'
|
||||
: 'text-slate-500 hover:text-slate-700 hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
{count > 0 && (
|
||||
<span
|
||||
className={`ml-1.5 inline-flex items-center justify-center px-1.5 py-0.5 rounded-full text-xs ${
|
||||
isActive
|
||||
? 'bg-emerald-100 text-emerald-700'
|
||||
: 'bg-slate-200 text-slate-500'
|
||||
}`}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="p-6">
|
||||
{activeTab === 'vvt' && renderVVTTab()}
|
||||
{activeTab === 'tom' && renderTOMTab()}
|
||||
{activeTab === 'risks' && renderRiskTab()}
|
||||
</div>
|
||||
|
||||
{/* Apply Button */}
|
||||
<div className="px-6 py-4 border-t border-slate-200 bg-slate-50">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-slate-500">
|
||||
Importiert alle Vorlagen, Empfehlungen und Szenarien in Ihr System.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleApplyPackage}
|
||||
disabled={applying}
|
||||
className={`inline-flex items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-semibold text-white transition-all ${
|
||||
applying
|
||||
? 'bg-emerald-400 cursor-not-allowed'
|
||||
: 'bg-gradient-to-r from-emerald-500 to-teal-600 hover:from-emerald-600 hover:to-teal-700 shadow-sm hover:shadow-md'
|
||||
}`}
|
||||
>
|
||||
{applying ? (
|
||||
<>
|
||||
<svg className="w-4 h-4 animate-spin" 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>
|
||||
Wird angewendet...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
Branchenpaket anwenden
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render: Toast
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const renderToast = () => {
|
||||
if (!toastMessage) return null
|
||||
return (
|
||||
<div className="fixed bottom-6 right-6 z-50 max-w-md animate-slide-up">
|
||||
<div className="bg-slate-900 text-white rounded-xl shadow-2xl px-5 py-4 flex items-start gap-3">
|
||||
<svg className="w-5 h-5 text-emerald-400 flex-shrink-0 mt-0.5" 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>
|
||||
<p className="text-sm leading-relaxed">{toastMessage}</p>
|
||||
<button
|
||||
onClick={() => setToastMessage(null)}
|
||||
className="text-slate-400 hover:text-white flex-shrink-0 ml-2"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render: Empty state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const renderEmptyState = () => (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-12 text-center">
|
||||
<div className="w-16 h-16 rounded-2xl bg-emerald-50 flex items-center justify-center text-3xl mx-auto mb-4">
|
||||
{'\uD83C\uDFED'}
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-slate-900">Keine Branchenvorlagen verfuegbar</h3>
|
||||
<p className="text-slate-500 mt-2 max-w-md mx-auto">
|
||||
Es sind derzeit keine branchenspezifischen Compliance-Pakete im System hinterlegt.
|
||||
Bitte kontaktieren Sie den Administrator oder versuchen Sie es spaeter erneut.
|
||||
</p>
|
||||
<button
|
||||
onClick={loadIndustries}
|
||||
className="mt-4 px-4 py-2 text-sm font-medium text-emerald-700 bg-emerald-50 hover:bg-emerald-100 rounded-lg transition-colors"
|
||||
>
|
||||
Erneut laden
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main Render
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Inline keyframe for toast animation */}
|
||||
<style>{`
|
||||
@keyframes slide-up {
|
||||
from { opacity: 0; transform: translateY(16px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.animate-slide-up {
|
||||
animation: slide-up 0.3s ease-out;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{renderHeader()}
|
||||
|
||||
{/* Error state */}
|
||||
{error && renderError(error, loadIndustries)}
|
||||
|
||||
{/* Main Content */}
|
||||
{loading ? (
|
||||
selectedSlug ? <DetailSkeleton /> : <GridSkeleton />
|
||||
) : selectedSlug ? (
|
||||
// Detail View
|
||||
<div className="space-y-6">
|
||||
{detailLoading ? (
|
||||
<DetailSkeleton />
|
||||
) : detailError ? (
|
||||
<>
|
||||
<button
|
||||
onClick={handleBackToGrid}
|
||||
className="inline-flex items-center gap-1.5 text-sm text-slate-500 hover:text-emerald-600 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>
|
||||
Zurueck zur Uebersicht
|
||||
</button>
|
||||
{renderError(detailError, () => loadDetail(selectedSlug))}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{renderDetailHeader()}
|
||||
{renderDetailContent()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : industries.length === 0 && !error ? (
|
||||
renderEmptyState()
|
||||
) : (
|
||||
renderGrid()
|
||||
)}
|
||||
|
||||
{/* Toast notification */}
|
||||
{renderToast()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,355 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useSDK, ServiceModule } from '@/lib/sdk'
|
||||
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
type ModuleCategory = 'gdpr' | 'ai-act' | 'iso27001' | 'nis2' | 'custom'
|
||||
type ModuleStatus = 'active' | 'inactive' | 'pending'
|
||||
|
||||
interface DisplayModule extends ServiceModule {
|
||||
category: ModuleCategory
|
||||
status: ModuleStatus
|
||||
requirementsCount: number
|
||||
controlsCount: number
|
||||
completionPercent: number
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// AVAILABLE MODULES (Templates)
|
||||
// =============================================================================
|
||||
|
||||
const availableModules: Omit<DisplayModule, 'status' | 'completionPercent'>[] = [
|
||||
{
|
||||
id: 'mod-gdpr',
|
||||
name: 'DSGVO Compliance',
|
||||
description: 'Datenschutz-Grundverordnung - Vollstaendige Umsetzung aller Anforderungen',
|
||||
category: 'gdpr',
|
||||
regulations: ['DSGVO', 'BDSG'],
|
||||
criticality: 'HIGH',
|
||||
processesPersonalData: true,
|
||||
hasAIComponents: false,
|
||||
requirementsCount: 45,
|
||||
controlsCount: 32,
|
||||
},
|
||||
{
|
||||
id: 'mod-ai-act',
|
||||
name: 'AI Act Compliance',
|
||||
description: 'EU AI Act - Klassifizierung und Anforderungen fuer KI-Systeme',
|
||||
category: 'ai-act',
|
||||
regulations: ['EU AI Act'],
|
||||
criticality: 'HIGH',
|
||||
processesPersonalData: false,
|
||||
hasAIComponents: true,
|
||||
requirementsCount: 28,
|
||||
controlsCount: 18,
|
||||
},
|
||||
{
|
||||
id: 'mod-iso27001',
|
||||
name: 'ISO 27001',
|
||||
description: 'Informationssicherheits-Managementsystem nach ISO/IEC 27001',
|
||||
category: 'iso27001',
|
||||
regulations: ['ISO 27001', 'ISO 27002'],
|
||||
criticality: 'MEDIUM',
|
||||
processesPersonalData: false,
|
||||
hasAIComponents: false,
|
||||
requirementsCount: 114,
|
||||
controlsCount: 93,
|
||||
},
|
||||
{
|
||||
id: 'mod-nis2',
|
||||
name: 'NIS2 Richtlinie',
|
||||
description: 'Netz- und Informationssicherheit fuer kritische Infrastrukturen',
|
||||
category: 'nis2',
|
||||
regulations: ['NIS2'],
|
||||
criticality: 'HIGH',
|
||||
processesPersonalData: false,
|
||||
hasAIComponents: false,
|
||||
requirementsCount: 36,
|
||||
controlsCount: 24,
|
||||
},
|
||||
]
|
||||
|
||||
// =============================================================================
|
||||
// COMPONENTS
|
||||
// =============================================================================
|
||||
|
||||
function ModuleCard({
|
||||
module,
|
||||
isActive,
|
||||
onActivate,
|
||||
onDeactivate,
|
||||
}: {
|
||||
module: DisplayModule
|
||||
isActive: boolean
|
||||
onActivate: () => void
|
||||
onDeactivate: () => void
|
||||
}) {
|
||||
const categoryColors = {
|
||||
gdpr: 'bg-blue-100 text-blue-700',
|
||||
'ai-act': 'bg-purple-100 text-purple-700',
|
||||
iso27001: 'bg-green-100 text-green-700',
|
||||
nis2: 'bg-orange-100 text-orange-700',
|
||||
custom: 'bg-gray-100 text-gray-700',
|
||||
}
|
||||
|
||||
const statusColors = {
|
||||
active: 'bg-green-100 text-green-700',
|
||||
inactive: 'bg-gray-100 text-gray-500',
|
||||
pending: 'bg-yellow-100 text-yellow-700',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-xl border-2 p-6 transition-all ${
|
||||
isActive ? 'border-purple-400 shadow-lg' : 'border-gray-200 hover:border-purple-300'
|
||||
}`}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${categoryColors[module.category]}`}>
|
||||
{module.category.toUpperCase()}
|
||||
</span>
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${statusColors[module.status]}`}>
|
||||
{module.status === 'active' ? 'Aktiv' : module.status === 'pending' ? 'Ausstehend' : 'Inaktiv'}
|
||||
</span>
|
||||
{module.hasAIComponents && (
|
||||
<span className="px-2 py-1 text-xs rounded-full bg-indigo-100 text-indigo-700">
|
||||
KI
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">{module.name}</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">{module.description}</p>
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{module.regulations.map(reg => (
|
||||
<span key={reg} className="px-2 py-0.5 text-xs bg-gray-100 text-gray-600 rounded">
|
||||
{reg}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500">Anforderungen:</span>
|
||||
<span className="ml-2 font-medium">{module.requirementsCount}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Kontrollen:</span>
|
||||
<span className="ml-2 font-medium">{module.controlsCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<div className="flex items-center justify-between text-sm mb-1">
|
||||
<span className="text-gray-500">Fortschritt</span>
|
||||
<span className="font-medium text-purple-600">{module.completionPercent}%</span>
|
||||
</div>
|
||||
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${
|
||||
module.completionPercent === 100 ? 'bg-green-500' : 'bg-purple-600'
|
||||
}`}
|
||||
style={{ width: `${module.completionPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
{isActive ? (
|
||||
<>
|
||||
<button className="flex-1 px-4 py-2 text-sm bg-purple-50 text-purple-700 rounded-lg hover:bg-purple-100 transition-colors">
|
||||
Konfigurieren
|
||||
</button>
|
||||
<button
|
||||
onClick={onDeactivate}
|
||||
className="px-4 py-2 text-sm text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||
>
|
||||
Deaktivieren
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={onActivate}
|
||||
className="flex-1 px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
Modul aktivieren
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN PAGE
|
||||
// =============================================================================
|
||||
|
||||
export default function ModulesPage() {
|
||||
const { state, dispatch } = useSDK()
|
||||
const [filter, setFilter] = useState<string>('all')
|
||||
|
||||
// Convert SDK modules to display modules with additional UI properties
|
||||
const displayModules: DisplayModule[] = availableModules.map(template => {
|
||||
const activeModule = state.modules.find(m => m.id === template.id)
|
||||
const isActive = !!activeModule
|
||||
|
||||
// Calculate completion based on linked requirements and controls
|
||||
const linkedRequirements = state.requirements.filter(r =>
|
||||
r.applicableModules.includes(template.id)
|
||||
)
|
||||
const completedRequirements = linkedRequirements.filter(
|
||||
r => r.status === 'IMPLEMENTED' || r.status === 'VERIFIED'
|
||||
)
|
||||
const completionPercent = linkedRequirements.length > 0
|
||||
? Math.round((completedRequirements.length / linkedRequirements.length) * 100)
|
||||
: 0
|
||||
|
||||
return {
|
||||
...template,
|
||||
status: isActive ? 'active' as ModuleStatus : 'inactive' as ModuleStatus,
|
||||
completionPercent,
|
||||
}
|
||||
})
|
||||
|
||||
const filteredModules = filter === 'all'
|
||||
? displayModules
|
||||
: displayModules.filter(m => m.category === filter || m.status === filter)
|
||||
|
||||
const activeModulesCount = state.modules.length
|
||||
const totalRequirements = displayModules
|
||||
.filter(m => state.modules.some(sm => sm.id === m.id))
|
||||
.reduce((sum, m) => sum + m.requirementsCount, 0)
|
||||
const totalControls = displayModules
|
||||
.filter(m => state.modules.some(sm => sm.id === m.id))
|
||||
.reduce((sum, m) => sum + m.controlsCount, 0)
|
||||
|
||||
const handleActivateModule = (module: DisplayModule) => {
|
||||
const serviceModule: ServiceModule = {
|
||||
id: module.id,
|
||||
name: module.name,
|
||||
description: module.description,
|
||||
regulations: module.regulations,
|
||||
criticality: module.criticality,
|
||||
processesPersonalData: module.processesPersonalData,
|
||||
hasAIComponents: module.hasAIComponents,
|
||||
}
|
||||
dispatch({ type: 'ADD_MODULE', payload: serviceModule })
|
||||
}
|
||||
|
||||
const handleDeactivateModule = (moduleId: string) => {
|
||||
// Remove module by updating state without it
|
||||
const updatedModules = state.modules.filter(m => m.id !== moduleId)
|
||||
dispatch({ type: 'SET_STATE', payload: { modules: updatedModules } })
|
||||
}
|
||||
|
||||
const stepInfo = STEP_EXPLANATIONS['modules']
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Step Header */}
|
||||
<StepHeader
|
||||
stepId="modules"
|
||||
title={stepInfo.title}
|
||||
description={stepInfo.description}
|
||||
explanation={stepInfo.explanation}
|
||||
tips={stepInfo.tips}
|
||||
>
|
||||
<button 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>
|
||||
Eigenes Modul erstellen
|
||||
</button>
|
||||
</StepHeader>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="text-sm text-gray-500">Verfuegbare Module</div>
|
||||
<div className="text-3xl font-bold text-gray-900">{availableModules.length}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-green-200 p-6">
|
||||
<div className="text-sm text-green-600">Aktivierte Module</div>
|
||||
<div className="text-3xl font-bold text-green-600">{activeModulesCount}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-blue-200 p-6">
|
||||
<div className="text-sm text-blue-600">Anforderungen (aktiv)</div>
|
||||
<div className="text-3xl font-bold text-blue-600">{totalRequirements}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-purple-200 p-6">
|
||||
<div className="text-sm text-purple-600">Kontrollen (aktiv)</div>
|
||||
<div className="text-3xl font-bold text-purple-600">{totalControls}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active Modules Alert */}
|
||||
{activeModulesCount === 0 && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<svg className="w-5 h-5 text-amber-600 mt-0.5" 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>
|
||||
<h4 className="font-medium text-amber-800">Keine Module aktiviert</h4>
|
||||
<p className="text-sm text-amber-700 mt-1">
|
||||
Aktivieren Sie mindestens ein Compliance-Modul, um mit der Erfassung von Anforderungen und Kontrollen fortzufahren.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filter */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-500">Filter:</span>
|
||||
{['all', 'active', 'inactive', 'gdpr', 'ai-act', 'iso27001', 'nis2'].map(f => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
className={`px-3 py-1 text-sm rounded-full transition-colors ${
|
||||
filter === f
|
||||
? 'bg-purple-600 text-white'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{f === 'all' ? 'Alle' :
|
||||
f === 'active' ? 'Aktiv' :
|
||||
f === 'inactive' ? 'Inaktiv' :
|
||||
f.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Module Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{filteredModules.map(module => (
|
||||
<ModuleCard
|
||||
key={module.id}
|
||||
module={module}
|
||||
isActive={state.modules.some(m => m.id === module.id)}
|
||||
onActivate={() => handleActivateModule(module)}
|
||||
onDeactivate={() => handleDeactivateModule(module.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredModules.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 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 2" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Keine Module gefunden</h3>
|
||||
<p className="mt-2 text-gray-500">Passen Sie den Filter an oder fuegen Sie neue Module hinzu.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,313 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { useSDK } from '@/lib/sdk'
|
||||
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
interface Obligation {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
source: string
|
||||
sourceArticle: string
|
||||
deadline: Date | null
|
||||
status: 'pending' | 'in-progress' | 'completed' | 'overdue'
|
||||
priority: 'critical' | 'high' | 'medium' | 'low'
|
||||
responsible: string
|
||||
linkedSystems: string[]
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MOCK DATA
|
||||
// =============================================================================
|
||||
|
||||
const mockObligations: Obligation[] = [
|
||||
{
|
||||
id: 'obl-1',
|
||||
title: 'Risikomanagementsystem implementieren',
|
||||
description: 'Ein Risikomanagementsystem fuer das Hochrisiko-KI-System muss implementiert werden.',
|
||||
source: 'AI Act',
|
||||
sourceArticle: 'Art. 9',
|
||||
deadline: new Date('2024-06-01'),
|
||||
status: 'in-progress',
|
||||
priority: 'critical',
|
||||
responsible: 'IT Security',
|
||||
linkedSystems: ['Bewerber-Screening'],
|
||||
},
|
||||
{
|
||||
id: 'obl-2',
|
||||
title: 'Technische Dokumentation erstellen',
|
||||
description: 'Umfassende technische Dokumentation fuer alle Hochrisiko-KI-Systeme.',
|
||||
source: 'AI Act',
|
||||
sourceArticle: 'Art. 11',
|
||||
deadline: new Date('2024-05-15'),
|
||||
status: 'pending',
|
||||
priority: 'high',
|
||||
responsible: 'Entwicklung',
|
||||
linkedSystems: ['Bewerber-Screening'],
|
||||
},
|
||||
{
|
||||
id: 'obl-3',
|
||||
title: 'Datenschutzerklaerung aktualisieren',
|
||||
description: 'Die Datenschutzerklaerung muss an die neuen KI-Verarbeitungen angepasst werden.',
|
||||
source: 'DSGVO',
|
||||
sourceArticle: 'Art. 13/14',
|
||||
deadline: new Date('2024-02-01'),
|
||||
status: 'overdue',
|
||||
priority: 'high',
|
||||
responsible: 'Datenschutz',
|
||||
linkedSystems: ['Kundenservice Chatbot', 'Empfehlungsalgorithmus'],
|
||||
},
|
||||
{
|
||||
id: 'obl-4',
|
||||
title: 'KI-Kennzeichnung implementieren',
|
||||
description: 'Nutzer muessen informiert werden, dass sie mit einem KI-System interagieren.',
|
||||
source: 'AI Act',
|
||||
sourceArticle: 'Art. 52',
|
||||
deadline: new Date('2024-03-01'),
|
||||
status: 'completed',
|
||||
priority: 'medium',
|
||||
responsible: 'UX Team',
|
||||
linkedSystems: ['Kundenservice Chatbot'],
|
||||
},
|
||||
{
|
||||
id: 'obl-5',
|
||||
title: 'Menschliche Aufsicht sicherstellen',
|
||||
description: 'Prozesse fuer menschliche Aufsicht bei automatisierten Entscheidungen.',
|
||||
source: 'AI Act',
|
||||
sourceArticle: 'Art. 14',
|
||||
deadline: new Date('2024-04-01'),
|
||||
status: 'pending',
|
||||
priority: 'critical',
|
||||
responsible: 'Operations',
|
||||
linkedSystems: ['Bewerber-Screening'],
|
||||
},
|
||||
]
|
||||
|
||||
// =============================================================================
|
||||
// COMPONENTS
|
||||
// =============================================================================
|
||||
|
||||
function ObligationCard({ obligation }: { obligation: Obligation }) {
|
||||
const priorityColors = {
|
||||
critical: 'bg-red-100 text-red-700',
|
||||
high: 'bg-orange-100 text-orange-700',
|
||||
medium: 'bg-yellow-100 text-yellow-700',
|
||||
low: 'bg-green-100 text-green-700',
|
||||
}
|
||||
|
||||
const statusColors = {
|
||||
pending: 'bg-gray-100 text-gray-600 border-gray-200',
|
||||
'in-progress': 'bg-blue-100 text-blue-700 border-blue-200',
|
||||
completed: 'bg-green-100 text-green-700 border-green-200',
|
||||
overdue: 'bg-red-100 text-red-700 border-red-200',
|
||||
}
|
||||
|
||||
const statusLabels = {
|
||||
pending: 'Ausstehend',
|
||||
'in-progress': 'In Bearbeitung',
|
||||
completed: 'Abgeschlossen',
|
||||
overdue: 'Ueberfaellig',
|
||||
}
|
||||
|
||||
const daysUntilDeadline = obligation.deadline
|
||||
? Math.ceil((obligation.deadline.getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24))
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-xl border-2 p-6 ${
|
||||
obligation.status === 'overdue' ? 'border-red-200' :
|
||||
obligation.status === 'completed' ? 'border-green-200' : 'border-gray-200'
|
||||
}`}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${priorityColors[obligation.priority]}`}>
|
||||
{obligation.priority === 'critical' ? 'Kritisch' :
|
||||
obligation.priority === 'high' ? 'Hoch' :
|
||||
obligation.priority === 'medium' ? 'Mittel' : 'Niedrig'}
|
||||
</span>
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${statusColors[obligation.status]}`}>
|
||||
{statusLabels[obligation.status]}
|
||||
</span>
|
||||
<span className="px-2 py-1 text-xs bg-purple-50 text-purple-700 rounded">
|
||||
{obligation.source} {obligation.sourceArticle}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">{obligation.title}</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">{obligation.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500">Verantwortlich: </span>
|
||||
<span className="font-medium text-gray-700">{obligation.responsible}</span>
|
||||
</div>
|
||||
{obligation.deadline && (
|
||||
<div className={daysUntilDeadline && daysUntilDeadline < 0 ? 'text-red-600' : ''}>
|
||||
<span className="text-gray-500">Frist: </span>
|
||||
<span className="font-medium">
|
||||
{obligation.deadline.toLocaleDateString('de-DE')}
|
||||
{daysUntilDeadline !== null && (
|
||||
<span className="ml-2">
|
||||
({daysUntilDeadline < 0 ? `${Math.abs(daysUntilDeadline)} Tage ueberfaellig` : `${daysUntilDeadline} Tage`})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{obligation.linkedSystems.length > 0 && (
|
||||
<div className="mt-3 flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm text-gray-500">Betroffene Systeme:</span>
|
||||
{obligation.linkedSystems.map(sys => (
|
||||
<span key={sys} className="px-2 py-0.5 text-xs bg-gray-100 text-gray-600 rounded">
|
||||
{sys}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between">
|
||||
<button className="text-sm text-purple-600 hover:text-purple-700 font-medium">
|
||||
Details anzeigen
|
||||
</button>
|
||||
{obligation.status !== 'completed' && (
|
||||
<button className="px-4 py-2 text-sm bg-purple-50 text-purple-700 rounded-lg hover:bg-purple-100 transition-colors">
|
||||
Als erledigt markieren
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN PAGE
|
||||
// =============================================================================
|
||||
|
||||
export default function ObligationsPage() {
|
||||
const { state } = useSDK()
|
||||
const [obligations] = useState<Obligation[]>(mockObligations)
|
||||
const [filter, setFilter] = useState<string>('all')
|
||||
|
||||
const filteredObligations = filter === 'all'
|
||||
? obligations
|
||||
: obligations.filter(o => o.status === filter || o.priority === filter || o.source.toLowerCase().includes(filter))
|
||||
|
||||
const pendingCount = obligations.filter(o => o.status === 'pending').length
|
||||
const inProgressCount = obligations.filter(o => o.status === 'in-progress').length
|
||||
const overdueCount = obligations.filter(o => o.status === 'overdue').length
|
||||
const completedCount = obligations.filter(o => o.status === 'completed').length
|
||||
|
||||
const stepInfo = STEP_EXPLANATIONS['obligations']
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Step Header */}
|
||||
<StepHeader
|
||||
stepId="obligations"
|
||||
title={stepInfo.title}
|
||||
description={stepInfo.description}
|
||||
explanation={stepInfo.explanation}
|
||||
tips={stepInfo.tips}
|
||||
>
|
||||
<button 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>
|
||||
Pflicht hinzufuegen
|
||||
</button>
|
||||
</StepHeader>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="text-sm text-gray-500">Ausstehend</div>
|
||||
<div className="text-3xl font-bold text-gray-900">{pendingCount}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-blue-200 p-6">
|
||||
<div className="text-sm text-blue-600">In Bearbeitung</div>
|
||||
<div className="text-3xl font-bold text-blue-600">{inProgressCount}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-red-200 p-6">
|
||||
<div className="text-sm text-red-600">Ueberfaellig</div>
|
||||
<div className="text-3xl font-bold text-red-600">{overdueCount}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-green-200 p-6">
|
||||
<div className="text-sm text-green-600">Abgeschlossen</div>
|
||||
<div className="text-3xl font-bold text-green-600">{completedCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Urgent Alert */}
|
||||
{overdueCount > 0 && (
|
||||
<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">
|
||||
<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>
|
||||
<h4 className="font-medium text-red-800">Achtung: {overdueCount} ueberfaellige Pflicht(en)</h4>
|
||||
<p className="text-sm text-red-600">Diese Pflichten erfordern sofortige Aufmerksamkeit.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filter */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm text-gray-500">Filter:</span>
|
||||
{['all', 'overdue', 'pending', 'in-progress', 'completed', 'critical', 'ai'].map(f => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
className={`px-3 py-1 text-sm rounded-full transition-colors ${
|
||||
filter === f
|
||||
? 'bg-purple-600 text-white'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{f === 'all' ? 'Alle' :
|
||||
f === 'overdue' ? 'Ueberfaellig' :
|
||||
f === 'pending' ? 'Ausstehend' :
|
||||
f === 'in-progress' ? 'In Bearbeitung' :
|
||||
f === 'completed' ? 'Abgeschlossen' :
|
||||
f === 'critical' ? 'Kritisch' : 'AI Act'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Obligations List */}
|
||||
<div className="space-y-4">
|
||||
{filteredObligations
|
||||
.sort((a, b) => {
|
||||
// Sort by status priority: overdue > in-progress > pending > completed
|
||||
const statusOrder = { overdue: 0, 'in-progress': 1, pending: 2, completed: 3 }
|
||||
return statusOrder[a.status] - statusOrder[b.status]
|
||||
})
|
||||
.map(obligation => (
|
||||
<ObligationCard key={obligation.id} obligation={obligation} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredObligations.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 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 2" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Keine Pflichten gefunden</h3>
|
||||
<p className="mt-2 text-gray-500">Passen Sie den Filter an oder fuegen Sie neue Pflichten hinzu.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,368 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { useSDK } from '@/lib/sdk'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
interface QualityMetric {
|
||||
id: string
|
||||
name: string
|
||||
category: 'accuracy' | 'fairness' | 'robustness' | 'explainability' | 'performance'
|
||||
score: number
|
||||
threshold: number
|
||||
trend: 'up' | 'down' | 'stable'
|
||||
lastMeasured: Date
|
||||
aiSystem: string
|
||||
}
|
||||
|
||||
interface QualityTest {
|
||||
id: string
|
||||
name: string
|
||||
status: 'passed' | 'failed' | 'warning' | 'pending'
|
||||
lastRun: Date
|
||||
duration: string
|
||||
aiSystem: string
|
||||
details: string
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MOCK DATA
|
||||
// =============================================================================
|
||||
|
||||
const mockMetrics: QualityMetric[] = [
|
||||
{
|
||||
id: 'm-1',
|
||||
name: 'Accuracy Score',
|
||||
category: 'accuracy',
|
||||
score: 94.5,
|
||||
threshold: 90,
|
||||
trend: 'up',
|
||||
lastMeasured: new Date('2024-01-22'),
|
||||
aiSystem: 'Bewerber-Screening',
|
||||
},
|
||||
{
|
||||
id: 'm-2',
|
||||
name: 'Fairness Index (Gender)',
|
||||
category: 'fairness',
|
||||
score: 87.2,
|
||||
threshold: 85,
|
||||
trend: 'stable',
|
||||
lastMeasured: new Date('2024-01-22'),
|
||||
aiSystem: 'Bewerber-Screening',
|
||||
},
|
||||
{
|
||||
id: 'm-3',
|
||||
name: 'Fairness Index (Age)',
|
||||
category: 'fairness',
|
||||
score: 78.5,
|
||||
threshold: 85,
|
||||
trend: 'down',
|
||||
lastMeasured: new Date('2024-01-22'),
|
||||
aiSystem: 'Bewerber-Screening',
|
||||
},
|
||||
{
|
||||
id: 'm-4',
|
||||
name: 'Robustness Score',
|
||||
category: 'robustness',
|
||||
score: 91.0,
|
||||
threshold: 85,
|
||||
trend: 'up',
|
||||
lastMeasured: new Date('2024-01-21'),
|
||||
aiSystem: 'Kundenservice Chatbot',
|
||||
},
|
||||
{
|
||||
id: 'm-5',
|
||||
name: 'Explainability Index',
|
||||
category: 'explainability',
|
||||
score: 72.3,
|
||||
threshold: 75,
|
||||
trend: 'up',
|
||||
lastMeasured: new Date('2024-01-22'),
|
||||
aiSystem: 'Empfehlungsalgorithmus',
|
||||
},
|
||||
{
|
||||
id: 'm-6',
|
||||
name: 'Response Time (P95)',
|
||||
category: 'performance',
|
||||
score: 95.0,
|
||||
threshold: 90,
|
||||
trend: 'stable',
|
||||
lastMeasured: new Date('2024-01-22'),
|
||||
aiSystem: 'Kundenservice Chatbot',
|
||||
},
|
||||
]
|
||||
|
||||
const mockTests: QualityTest[] = [
|
||||
{
|
||||
id: 't-1',
|
||||
name: 'Bias Detection Test',
|
||||
status: 'warning',
|
||||
lastRun: new Date('2024-01-22T10:30:00'),
|
||||
duration: '45min',
|
||||
aiSystem: 'Bewerber-Screening',
|
||||
details: 'Leichte Verzerrung bei Altersgruppe 50+ erkannt',
|
||||
},
|
||||
{
|
||||
id: 't-2',
|
||||
name: 'Accuracy Benchmark',
|
||||
status: 'passed',
|
||||
lastRun: new Date('2024-01-22T08:00:00'),
|
||||
duration: '2h 15min',
|
||||
aiSystem: 'Bewerber-Screening',
|
||||
details: 'Alle Schwellenwerte eingehalten',
|
||||
},
|
||||
{
|
||||
id: 't-3',
|
||||
name: 'Adversarial Testing',
|
||||
status: 'passed',
|
||||
lastRun: new Date('2024-01-21T14:00:00'),
|
||||
duration: '1h 30min',
|
||||
aiSystem: 'Kundenservice Chatbot',
|
||||
details: 'System robust gegen Manipulation',
|
||||
},
|
||||
{
|
||||
id: 't-4',
|
||||
name: 'Explainability Test',
|
||||
status: 'failed',
|
||||
lastRun: new Date('2024-01-22T09:00:00'),
|
||||
duration: '30min',
|
||||
aiSystem: 'Empfehlungsalgorithmus',
|
||||
details: 'SHAP-Werte unter Schwellenwert',
|
||||
},
|
||||
{
|
||||
id: 't-5',
|
||||
name: 'Performance Load Test',
|
||||
status: 'passed',
|
||||
lastRun: new Date('2024-01-22T06:00:00'),
|
||||
duration: '3h',
|
||||
aiSystem: 'Kundenservice Chatbot',
|
||||
details: '10.000 gleichzeitige Anfragen verarbeitet',
|
||||
},
|
||||
]
|
||||
|
||||
// =============================================================================
|
||||
// COMPONENTS
|
||||
// =============================================================================
|
||||
|
||||
function MetricCard({ metric }: { metric: QualityMetric }) {
|
||||
const isAboveThreshold = metric.score >= metric.threshold
|
||||
const categoryColors = {
|
||||
accuracy: 'bg-blue-100 text-blue-700',
|
||||
fairness: 'bg-purple-100 text-purple-700',
|
||||
robustness: 'bg-green-100 text-green-700',
|
||||
explainability: 'bg-yellow-100 text-yellow-700',
|
||||
performance: 'bg-orange-100 text-orange-700',
|
||||
}
|
||||
|
||||
const categoryLabels = {
|
||||
accuracy: 'Genauigkeit',
|
||||
fairness: 'Fairness',
|
||||
robustness: 'Robustheit',
|
||||
explainability: 'Erklaerbarkeit',
|
||||
performance: 'Performance',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-xl border-2 p-6 ${
|
||||
isAboveThreshold ? 'border-gray-200' : 'border-red-200'
|
||||
}`}>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${categoryColors[metric.category]}`}>
|
||||
{categoryLabels[metric.category]}
|
||||
</span>
|
||||
<h4 className="font-semibold text-gray-900 mt-2">{metric.name}</h4>
|
||||
<p className="text-xs text-gray-500">{metric.aiSystem}</p>
|
||||
</div>
|
||||
<div className={`flex items-center gap-1 text-sm ${
|
||||
metric.trend === 'up' ? 'text-green-600' :
|
||||
metric.trend === 'down' ? 'text-red-600' : 'text-gray-500'
|
||||
}`}>
|
||||
{metric.trend === 'up' && (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 10l7-7m0 0l7 7m-7-7v18" />
|
||||
</svg>
|
||||
)}
|
||||
{metric.trend === 'down' && (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 14l-7 7m0 0l-7-7m7 7V3" />
|
||||
</svg>
|
||||
)}
|
||||
{metric.trend === 'stable' && (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end justify-between">
|
||||
<div>
|
||||
<div className={`text-3xl font-bold ${isAboveThreshold ? 'text-gray-900' : 'text-red-600'}`}>
|
||||
{metric.score}%
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
Schwellenwert: {metric.threshold}%
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-24 h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full ${isAboveThreshold ? 'bg-green-500' : 'bg-red-500'}`}
|
||||
style={{ width: `${metric.score}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TestRow({ test }: { test: QualityTest }) {
|
||||
const statusColors = {
|
||||
passed: 'bg-green-100 text-green-700',
|
||||
failed: 'bg-red-100 text-red-700',
|
||||
warning: 'bg-yellow-100 text-yellow-700',
|
||||
pending: 'bg-gray-100 text-gray-500',
|
||||
}
|
||||
|
||||
const statusLabels = {
|
||||
passed: 'Bestanden',
|
||||
failed: 'Fehlgeschlagen',
|
||||
warning: 'Warnung',
|
||||
pending: 'Ausstehend',
|
||||
}
|
||||
|
||||
return (
|
||||
<tr className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4">
|
||||
<div className="font-medium text-gray-900">{test.name}</div>
|
||||
<div className="text-xs text-gray-500">{test.aiSystem}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${statusColors[test.status]}`}>
|
||||
{statusLabels[test.status]}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500">
|
||||
{test.lastRun.toLocaleString('de-DE')}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500">{test.duration}</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500 max-w-xs truncate">{test.details}</td>
|
||||
<td className="px-6 py-4">
|
||||
<button className="text-sm text-purple-600 hover:text-purple-700">Details</button>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN PAGE
|
||||
// =============================================================================
|
||||
|
||||
export default function QualityPage() {
|
||||
const { state } = useSDK()
|
||||
const [metrics] = useState<QualityMetric[]>(mockMetrics)
|
||||
const [tests] = useState<QualityTest[]>(mockTests)
|
||||
|
||||
const passedTests = tests.filter(t => t.status === 'passed').length
|
||||
const failedTests = tests.filter(t => t.status === 'failed').length
|
||||
const metricsAboveThreshold = metrics.filter(m => m.score >= m.threshold).length
|
||||
const avgScore = Math.round(metrics.reduce((sum, m) => sum + m.score, 0) / metrics.length)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">AI Quality Dashboard</h1>
|
||||
<p className="mt-1 text-gray-500">
|
||||
Ueberwachen Sie die Qualitaet und Fairness Ihrer KI-Systeme
|
||||
</p>
|
||||
</div>
|
||||
<button 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="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>
|
||||
Tests ausfuehren
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="text-sm text-gray-500">Durchschnittlicher Score</div>
|
||||
<div className="text-3xl font-bold text-gray-900">{avgScore}%</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-green-200 p-6">
|
||||
<div className="text-sm text-green-600">Metriken ueber Schwellenwert</div>
|
||||
<div className="text-3xl font-bold text-green-600">{metricsAboveThreshold}/{metrics.length}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-green-200 p-6">
|
||||
<div className="text-sm text-green-600">Tests bestanden</div>
|
||||
<div className="text-3xl font-bold text-green-600">{passedTests}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-red-200 p-6">
|
||||
<div className="text-sm text-red-600">Tests fehlgeschlagen</div>
|
||||
<div className="text-3xl font-bold text-red-600">{failedTests}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Alert for failed metrics */}
|
||||
{metrics.filter(m => m.score < m.threshold).length > 0 && (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-xl p-4 flex items-center gap-4">
|
||||
<div className="w-10 h-10 bg-yellow-100 rounded-full flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-yellow-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>
|
||||
<h4 className="font-medium text-yellow-800">
|
||||
{metrics.filter(m => m.score < m.threshold).length} Metrik(en) unter Schwellenwert
|
||||
</h4>
|
||||
<p className="text-sm text-yellow-600">
|
||||
Ueberpruefen Sie die betroffenen KI-Systeme und ergreifen Sie Korrekturmassnahmen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Metrics Grid */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Qualitaetsmetriken</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{metrics.map(metric => (
|
||||
<MetricCard key={metric.id} metric={metric} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tests Table */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Testergebnisse</h3>
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Test</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Letzter Lauf</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Dauer</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Details</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{tests.map(test => (
|
||||
<TestRow key={test.id} test={test} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,427 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useSDK, Requirement as SDKRequirement, RequirementStatus, RiskSeverity } from '@/lib/sdk'
|
||||
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
type DisplayPriority = 'critical' | 'high' | 'medium' | 'low'
|
||||
type DisplayStatus = 'compliant' | 'partial' | 'non-compliant' | 'not-applicable'
|
||||
|
||||
interface DisplayRequirement extends SDKRequirement {
|
||||
code: string
|
||||
source: string
|
||||
category: string
|
||||
priority: DisplayPriority
|
||||
displayStatus: DisplayStatus
|
||||
controlsLinked: number
|
||||
evidenceCount: number
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// =============================================================================
|
||||
|
||||
function mapCriticalityToPriority(criticality: RiskSeverity): DisplayPriority {
|
||||
switch (criticality) {
|
||||
case 'CRITICAL': return 'critical'
|
||||
case 'HIGH': return 'high'
|
||||
case 'MEDIUM': return 'medium'
|
||||
case 'LOW': return 'low'
|
||||
default: return 'medium'
|
||||
}
|
||||
}
|
||||
|
||||
function mapStatusToDisplayStatus(status: RequirementStatus): DisplayStatus {
|
||||
switch (status) {
|
||||
case 'VERIFIED':
|
||||
case 'IMPLEMENTED': return 'compliant'
|
||||
case 'IN_PROGRESS': return 'partial'
|
||||
case 'NOT_STARTED': return 'non-compliant'
|
||||
default: return 'non-compliant'
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// AVAILABLE REQUIREMENTS (Templates)
|
||||
// =============================================================================
|
||||
|
||||
const requirementTemplates: Omit<DisplayRequirement, 'displayStatus' | 'controlsLinked' | 'evidenceCount'>[] = [
|
||||
{
|
||||
id: 'req-gdpr-6',
|
||||
regulation: 'DSGVO',
|
||||
article: 'Art. 6',
|
||||
code: 'GDPR-6.1',
|
||||
title: 'Rechtmaessigkeit der Verarbeitung',
|
||||
description: 'Personenbezogene Daten duerfen nur verarbeitet werden, wenn eine Rechtsgrundlage vorliegt.',
|
||||
source: 'DSGVO Art. 6',
|
||||
category: 'Rechtmaessigkeit',
|
||||
priority: 'critical',
|
||||
criticality: 'CRITICAL',
|
||||
applicableModules: ['mod-gdpr'],
|
||||
status: 'NOT_STARTED',
|
||||
controls: [],
|
||||
},
|
||||
{
|
||||
id: 'req-gdpr-13',
|
||||
regulation: 'DSGVO',
|
||||
article: 'Art. 13/14',
|
||||
code: 'GDPR-13',
|
||||
title: 'Informationspflichten',
|
||||
description: 'Betroffene Personen muessen ueber die Datenverarbeitung informiert werden.',
|
||||
source: 'DSGVO Art. 13/14',
|
||||
category: 'Transparenz',
|
||||
priority: 'high',
|
||||
criticality: 'HIGH',
|
||||
applicableModules: ['mod-gdpr'],
|
||||
status: 'NOT_STARTED',
|
||||
controls: [],
|
||||
},
|
||||
{
|
||||
id: 'req-ai-act-9',
|
||||
regulation: 'AI Act',
|
||||
article: 'Art. 9',
|
||||
code: 'AI-ACT-9',
|
||||
title: 'Risikomanagementsystem',
|
||||
description: 'Hochrisiko-KI-Systeme erfordern ein Risikomanagementsystem.',
|
||||
source: 'AI Act Art. 9',
|
||||
category: 'KI-Governance',
|
||||
priority: 'high',
|
||||
criticality: 'HIGH',
|
||||
applicableModules: ['mod-ai-act'],
|
||||
status: 'NOT_STARTED',
|
||||
controls: [],
|
||||
},
|
||||
{
|
||||
id: 'req-gdpr-32',
|
||||
regulation: 'DSGVO',
|
||||
article: 'Art. 32',
|
||||
code: 'GDPR-32',
|
||||
title: 'Sicherheit der Verarbeitung',
|
||||
description: 'Geeignete technische und organisatorische Massnahmen zur Datensicherheit.',
|
||||
source: 'DSGVO Art. 32',
|
||||
category: 'Sicherheit',
|
||||
priority: 'critical',
|
||||
criticality: 'CRITICAL',
|
||||
applicableModules: ['mod-gdpr', 'mod-iso27001'],
|
||||
status: 'NOT_STARTED',
|
||||
controls: [],
|
||||
},
|
||||
{
|
||||
id: 'req-gdpr-35',
|
||||
regulation: 'DSGVO',
|
||||
article: 'Art. 35',
|
||||
code: 'GDPR-35',
|
||||
title: 'Datenschutz-Folgenabschaetzung',
|
||||
description: 'Bei hohem Risiko ist eine DSFA durchzufuehren.',
|
||||
source: 'DSGVO Art. 35',
|
||||
category: 'Risikobewertung',
|
||||
priority: 'high',
|
||||
criticality: 'HIGH',
|
||||
applicableModules: ['mod-gdpr'],
|
||||
status: 'NOT_STARTED',
|
||||
controls: [],
|
||||
},
|
||||
{
|
||||
id: 'req-ai-act-13',
|
||||
regulation: 'AI Act',
|
||||
article: 'Art. 13',
|
||||
code: 'AI-ACT-13',
|
||||
title: 'Transparenzanforderungen',
|
||||
description: 'KI-Systeme muessen fuer Nutzer nachvollziehbar und transparent sein.',
|
||||
source: 'AI Act Art. 13',
|
||||
category: 'Transparenz',
|
||||
priority: 'high',
|
||||
criticality: 'HIGH',
|
||||
applicableModules: ['mod-ai-act'],
|
||||
status: 'NOT_STARTED',
|
||||
controls: [],
|
||||
},
|
||||
{
|
||||
id: 'req-nis2-21',
|
||||
regulation: 'NIS2',
|
||||
article: 'Art. 21',
|
||||
code: 'NIS2-21',
|
||||
title: 'Risikomanagementmassnahmen',
|
||||
description: 'Wesentliche und wichtige Einrichtungen muessen Cybersicherheitsmassnahmen implementieren.',
|
||||
source: 'NIS2 Art. 21',
|
||||
category: 'Cybersicherheit',
|
||||
priority: 'high',
|
||||
criticality: 'HIGH',
|
||||
applicableModules: ['mod-nis2'],
|
||||
status: 'NOT_STARTED',
|
||||
controls: [],
|
||||
},
|
||||
]
|
||||
|
||||
// =============================================================================
|
||||
// COMPONENTS
|
||||
// =============================================================================
|
||||
|
||||
function RequirementCard({
|
||||
requirement,
|
||||
onStatusChange,
|
||||
}: {
|
||||
requirement: DisplayRequirement
|
||||
onStatusChange: (status: RequirementStatus) => void
|
||||
}) {
|
||||
const priorityColors = {
|
||||
critical: 'bg-red-100 text-red-700',
|
||||
high: 'bg-orange-100 text-orange-700',
|
||||
medium: 'bg-yellow-100 text-yellow-700',
|
||||
low: 'bg-green-100 text-green-700',
|
||||
}
|
||||
|
||||
const statusColors = {
|
||||
compliant: 'bg-green-100 text-green-700 border-green-200',
|
||||
partial: 'bg-yellow-100 text-yellow-700 border-yellow-200',
|
||||
'non-compliant': 'bg-red-100 text-red-700 border-red-200',
|
||||
'not-applicable': 'bg-gray-100 text-gray-500 border-gray-200',
|
||||
}
|
||||
|
||||
const statusLabels = {
|
||||
compliant: 'Konform',
|
||||
partial: 'Teilweise',
|
||||
'non-compliant': 'Nicht konform',
|
||||
'not-applicable': 'N/A',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-xl border-2 p-6 ${statusColors[requirement.displayStatus]}`}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="px-2 py-1 text-xs bg-gray-100 text-gray-700 rounded font-mono">
|
||||
{requirement.code}
|
||||
</span>
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${priorityColors[requirement.priority]}`}>
|
||||
{requirement.priority === 'critical' ? 'Kritisch' :
|
||||
requirement.priority === 'high' ? 'Hoch' :
|
||||
requirement.priority === 'medium' ? 'Mittel' : 'Niedrig'}
|
||||
</span>
|
||||
<span className="px-2 py-1 text-xs bg-blue-50 text-blue-600 rounded">
|
||||
{requirement.regulation}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">{requirement.title}</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">{requirement.description}</p>
|
||||
<p className="text-xs text-gray-400 mt-2">Quelle: {requirement.source}</p>
|
||||
</div>
|
||||
<select
|
||||
value={requirement.status}
|
||||
onChange={(e) => onStatusChange(e.target.value as RequirementStatus)}
|
||||
className={`px-3 py-1 text-sm rounded-full border ${statusColors[requirement.displayStatus]}`}
|
||||
>
|
||||
<option value="NOT_STARTED">Nicht begonnen</option>
|
||||
<option value="IN_PROGRESS">In Bearbeitung</option>
|
||||
<option value="IMPLEMENTED">Implementiert</option>
|
||||
<option value="VERIFIED">Verifiziert</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<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>{requirement.controlsLinked} Kontrollen</span>
|
||||
<span>{requirement.evidenceCount} Nachweise</span>
|
||||
</div>
|
||||
<button className="text-sm text-purple-600 hover:text-purple-700 font-medium">
|
||||
Details anzeigen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN PAGE
|
||||
// =============================================================================
|
||||
|
||||
export default function RequirementsPage() {
|
||||
const { state, dispatch } = useSDK()
|
||||
const [filter, setFilter] = useState<string>('all')
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
|
||||
// Load requirements based on active modules
|
||||
useEffect(() => {
|
||||
// Only add requirements if there are active modules and no requirements yet
|
||||
if (state.modules.length > 0 && state.requirements.length === 0) {
|
||||
const activeModuleIds = state.modules.map(m => m.id)
|
||||
const relevantRequirements = requirementTemplates.filter(r =>
|
||||
r.applicableModules.some(m => activeModuleIds.includes(m))
|
||||
)
|
||||
|
||||
relevantRequirements.forEach(req => {
|
||||
const sdkRequirement: SDKRequirement = {
|
||||
id: req.id,
|
||||
regulation: req.regulation,
|
||||
article: req.article,
|
||||
title: req.title,
|
||||
description: req.description,
|
||||
criticality: req.criticality,
|
||||
applicableModules: req.applicableModules,
|
||||
status: 'NOT_STARTED',
|
||||
controls: [],
|
||||
}
|
||||
dispatch({ type: 'ADD_REQUIREMENT', payload: sdkRequirement })
|
||||
})
|
||||
}
|
||||
}, [state.modules, state.requirements.length, dispatch])
|
||||
|
||||
// Convert SDK requirements to display requirements
|
||||
const displayRequirements: DisplayRequirement[] = state.requirements.map(req => {
|
||||
const template = requirementTemplates.find(t => t.id === req.id)
|
||||
const linkedControls = state.controls.filter(c => c.evidence.includes(req.id))
|
||||
const linkedEvidence = state.evidence.filter(e => e.controlId && linkedControls.some(c => c.id === e.controlId))
|
||||
|
||||
return {
|
||||
...req,
|
||||
code: template?.code || req.id,
|
||||
source: template?.source || `${req.regulation} ${req.article}`,
|
||||
category: template?.category || req.regulation,
|
||||
priority: mapCriticalityToPriority(req.criticality),
|
||||
displayStatus: mapStatusToDisplayStatus(req.status),
|
||||
controlsLinked: linkedControls.length,
|
||||
evidenceCount: linkedEvidence.length,
|
||||
}
|
||||
})
|
||||
|
||||
const filteredRequirements = displayRequirements.filter(req => {
|
||||
const matchesFilter = filter === 'all' ||
|
||||
req.displayStatus === filter ||
|
||||
req.priority === filter
|
||||
const matchesSearch = searchQuery === '' ||
|
||||
req.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
req.code.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
return matchesFilter && matchesSearch
|
||||
})
|
||||
|
||||
const compliantCount = displayRequirements.filter(r => r.displayStatus === 'compliant').length
|
||||
const partialCount = displayRequirements.filter(r => r.displayStatus === 'partial').length
|
||||
const nonCompliantCount = displayRequirements.filter(r => r.displayStatus === 'non-compliant').length
|
||||
|
||||
const handleStatusChange = (requirementId: string, status: RequirementStatus) => {
|
||||
dispatch({
|
||||
type: 'UPDATE_REQUIREMENT',
|
||||
payload: { id: requirementId, data: { status } },
|
||||
})
|
||||
}
|
||||
|
||||
const stepInfo = STEP_EXPLANATIONS['requirements']
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Step Header */}
|
||||
<StepHeader
|
||||
stepId="requirements"
|
||||
title={stepInfo.title}
|
||||
description={stepInfo.description}
|
||||
explanation={stepInfo.explanation}
|
||||
tips={stepInfo.tips}
|
||||
>
|
||||
<button 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>
|
||||
Anforderung hinzufuegen
|
||||
</button>
|
||||
</StepHeader>
|
||||
|
||||
{/* Module Alert */}
|
||||
{state.modules.length === 0 && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<svg className="w-5 h-5 text-amber-600 mt-0.5" 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>
|
||||
<h4 className="font-medium text-amber-800">Keine Module aktiviert</h4>
|
||||
<p className="text-sm text-amber-700 mt-1">
|
||||
Bitte aktivieren Sie zuerst Compliance-Module, um die zugehoerigen Anforderungen zu laden.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="text-sm text-gray-500">Gesamt</div>
|
||||
<div className="text-3xl font-bold text-gray-900">{displayRequirements.length}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-green-200 p-6">
|
||||
<div className="text-sm text-green-600">Konform</div>
|
||||
<div className="text-3xl font-bold text-green-600">{compliantCount}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-yellow-200 p-6">
|
||||
<div className="text-sm text-yellow-600">In Bearbeitung</div>
|
||||
<div className="text-3xl font-bold text-yellow-600">{partialCount}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-red-200 p-6">
|
||||
<div className="text-sm text-red-600">Offen</div>
|
||||
<div className="text-3xl font-bold text-red-600">{nonCompliantCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search and Filter */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1 relative">
|
||||
<svg className="w-5 h-5 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" 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>
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Anforderungen durchsuchen..."
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{['all', 'compliant', 'partial', 'non-compliant', 'critical'].map(f => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
className={`px-3 py-1 text-sm rounded-full transition-colors ${
|
||||
filter === f
|
||||
? 'bg-purple-600 text-white'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{f === 'all' ? 'Alle' :
|
||||
f === 'compliant' ? 'Konform' :
|
||||
f === 'partial' ? 'Teilweise' :
|
||||
f === 'non-compliant' ? 'Offen' : 'Kritisch'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Requirements List */}
|
||||
<div className="space-y-4">
|
||||
{filteredRequirements.map(requirement => (
|
||||
<RequirementCard
|
||||
key={requirement.id}
|
||||
requirement={requirement}
|
||||
onStatusChange={(status) => handleStatusChange(requirement.id, status)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredRequirements.length === 0 && state.modules.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 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 2" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Keine Anforderungen gefunden</h3>
|
||||
<p className="mt-2 text-gray-500">Passen Sie die Suche oder den Filter an.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,531 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { useSDK, Risk, RiskLikelihood, RiskImpact, RiskSeverity, calculateRiskScore, getRiskSeverityFromScore } from '@/lib/sdk'
|
||||
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
||||
|
||||
// =============================================================================
|
||||
// RISK MATRIX
|
||||
// =============================================================================
|
||||
|
||||
function RiskMatrix({ risks, onCellClick }: { risks: Risk[]; onCellClick: (l: number, i: number) => void }) {
|
||||
const matrix: Record<string, Risk[]> = {}
|
||||
|
||||
risks.forEach(risk => {
|
||||
const key = `${risk.likelihood}-${risk.impact}`
|
||||
if (!matrix[key]) matrix[key] = []
|
||||
matrix[key].push(risk)
|
||||
})
|
||||
|
||||
const getCellColor = (likelihood: number, impact: number): string => {
|
||||
const score = likelihood * impact
|
||||
if (score >= 20) return 'bg-red-500'
|
||||
if (score >= 15) return 'bg-red-400'
|
||||
if (score >= 12) return 'bg-orange-400'
|
||||
if (score >= 8) return 'bg-yellow-400'
|
||||
if (score >= 4) return 'bg-yellow-300'
|
||||
return 'bg-green-400'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">5x5 Risikomatrix</h3>
|
||||
<div className="flex">
|
||||
{/* Y-Axis Label */}
|
||||
<div className="flex flex-col justify-center pr-2">
|
||||
<div className="transform -rotate-90 whitespace-nowrap text-sm text-gray-500 font-medium">
|
||||
Wahrscheinlichkeit
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
{/* Matrix Grid */}
|
||||
<div className="grid grid-cols-5 gap-1">
|
||||
{[5, 4, 3, 2, 1].map(likelihood => (
|
||||
<React.Fragment key={likelihood}>
|
||||
{[1, 2, 3, 4, 5].map(impact => {
|
||||
const key = `${likelihood}-${impact}`
|
||||
const cellRisks = matrix[key] || []
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => onCellClick(likelihood, impact)}
|
||||
className={`aspect-square rounded-lg ${getCellColor(
|
||||
likelihood,
|
||||
impact
|
||||
)} hover:opacity-80 transition-opacity relative`}
|
||||
>
|
||||
{cellRisks.length > 0 && (
|
||||
<span className="absolute inset-0 flex items-center justify-center text-white font-bold text-lg">
|
||||
{cellRisks.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* X-Axis Label */}
|
||||
<div className="mt-2 text-center text-sm text-gray-500 font-medium">Auswirkung</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="mt-6 flex items-center justify-center gap-4 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded bg-green-400" />
|
||||
<span>Niedrig</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded bg-yellow-400" />
|
||||
<span>Mittel</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded bg-orange-400" />
|
||||
<span>Hoch</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded bg-red-500" />
|
||||
<span>Kritisch</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// RISK FORM
|
||||
// =============================================================================
|
||||
|
||||
interface RiskFormData {
|
||||
title: string
|
||||
description: string
|
||||
category: string
|
||||
likelihood: RiskLikelihood
|
||||
impact: RiskImpact
|
||||
}
|
||||
|
||||
function RiskForm({
|
||||
onSubmit,
|
||||
onCancel,
|
||||
initialData,
|
||||
}: {
|
||||
onSubmit: (data: RiskFormData) => void
|
||||
onCancel: () => void
|
||||
initialData?: Partial<RiskFormData>
|
||||
}) {
|
||||
const [formData, setFormData] = useState<RiskFormData>({
|
||||
title: initialData?.title || '',
|
||||
description: initialData?.description || '',
|
||||
category: initialData?.category || 'technical',
|
||||
likelihood: initialData?.likelihood || 3,
|
||||
impact: initialData?.impact || 3,
|
||||
})
|
||||
|
||||
const score = calculateRiskScore(formData.likelihood, formData.impact)
|
||||
const severity = getRiskSeverityFromScore(score)
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
{initialData ? 'Risiko bearbeiten' : 'Neues Risiko'}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Titel *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.title}
|
||||
onChange={e => setFormData({ ...formData, title: e.target.value })}
|
||||
placeholder="z.B. Datenverlust durch Systemausfall"
|
||||
className="w-full px-4 py-2 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-1">Beschreibung</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={e => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="Beschreiben Sie das Risiko..."
|
||||
rows={3}
|
||||
className="w-full px-4 py-2 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-1">Kategorie</label>
|
||||
<select
|
||||
value={formData.category}
|
||||
onChange={e => setFormData({ ...formData, category: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
>
|
||||
<option value="technical">Technisch</option>
|
||||
<option value="organizational">Organisatorisch</option>
|
||||
<option value="legal">Rechtlich</option>
|
||||
<option value="operational">Operativ</option>
|
||||
<option value="strategic">Strategisch</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Wahrscheinlichkeit (1-5)
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={5}
|
||||
value={formData.likelihood}
|
||||
onChange={e => setFormData({ ...formData, likelihood: Number(e.target.value) as RiskLikelihood })}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-500 mt-1">
|
||||
<span>Sehr unwahrscheinlich</span>
|
||||
<span className="font-bold">{formData.likelihood}</span>
|
||||
<span>Sehr wahrscheinlich</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Auswirkung (1-5)</label>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={5}
|
||||
value={formData.impact}
|
||||
onChange={e => setFormData({ ...formData, impact: Number(e.target.value) as RiskImpact })}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-500 mt-1">
|
||||
<span>Gering</span>
|
||||
<span className="font-bold">{formData.impact}</span>
|
||||
<span>Katastrophal</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Risk Score Preview */}
|
||||
<div
|
||||
className={`p-4 rounded-lg ${
|
||||
severity === 'CRITICAL'
|
||||
? 'bg-red-50 border border-red-200'
|
||||
: severity === 'HIGH'
|
||||
? 'bg-orange-50 border border-orange-200'
|
||||
: severity === 'MEDIUM'
|
||||
? 'bg-yellow-50 border border-yellow-200'
|
||||
: 'bg-green-50 border border-green-200'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Berechneter Risikoscore:</span>
|
||||
<span
|
||||
className={`px-3 py-1 rounded-full text-sm font-bold ${
|
||||
severity === 'CRITICAL'
|
||||
? 'bg-red-100 text-red-700'
|
||||
: severity === 'HIGH'
|
||||
? 'bg-orange-100 text-orange-700'
|
||||
: severity === 'MEDIUM'
|
||||
? 'bg-yellow-100 text-yellow-700'
|
||||
: 'bg-green-100 text-green-700'
|
||||
}`}
|
||||
>
|
||||
{score} ({severity})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex items-center justify-end gap-3">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onSubmit(formData)}
|
||||
disabled={!formData.title}
|
||||
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
|
||||
formData.title
|
||||
? 'bg-purple-600 text-white hover:bg-purple-700'
|
||||
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// RISK CARD
|
||||
// =============================================================================
|
||||
|
||||
function RiskCard({
|
||||
risk,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
risk: Risk
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
}) {
|
||||
const severityColors = {
|
||||
CRITICAL: 'border-red-200 bg-red-50',
|
||||
HIGH: 'border-orange-200 bg-orange-50',
|
||||
MEDIUM: 'border-yellow-200 bg-yellow-50',
|
||||
LOW: 'border-green-200 bg-green-50',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-xl border-2 p-6 ${severityColors[risk.severity]}`}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="font-semibold text-gray-900">{risk.title}</h4>
|
||||
<span
|
||||
className={`px-2 py-0.5 text-xs rounded-full ${
|
||||
risk.severity === 'CRITICAL'
|
||||
? 'bg-red-100 text-red-700'
|
||||
: risk.severity === 'HIGH'
|
||||
? 'bg-orange-100 text-orange-700'
|
||||
: risk.severity === 'MEDIUM'
|
||||
? 'bg-yellow-100 text-yellow-700'
|
||||
: 'bg-green-100 text-green-700'
|
||||
}`}
|
||||
>
|
||||
{risk.severity}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-1">{risk.description}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg 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="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>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg 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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500">Wahrscheinlichkeit:</span>
|
||||
<span className="ml-2 font-medium">{risk.likelihood}/5</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Auswirkung:</span>
|
||||
<span className="ml-2 font-medium">{risk.impact}/5</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Score:</span>
|
||||
<span className="ml-2 font-medium">{risk.inherentRiskScore}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{risk.mitigation.length > 0 && (
|
||||
<div className="mt-4 pt-4 border-t border-gray-200">
|
||||
<span className="text-sm text-gray-500">Mitigationen: {risk.mitigation.length}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN PAGE
|
||||
// =============================================================================
|
||||
|
||||
export default function RisksPage() {
|
||||
const { state, dispatch, addRisk } = useSDK()
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [editingRisk, setEditingRisk] = useState<Risk | null>(null)
|
||||
|
||||
const handleSubmit = (data: { title: string; description: string; category: string; likelihood: RiskLikelihood; impact: RiskImpact }) => {
|
||||
const score = calculateRiskScore(data.likelihood, data.impact)
|
||||
const severity = getRiskSeverityFromScore(score)
|
||||
|
||||
if (editingRisk) {
|
||||
dispatch({
|
||||
type: 'UPDATE_RISK',
|
||||
payload: {
|
||||
id: editingRisk.id,
|
||||
data: {
|
||||
...data,
|
||||
severity,
|
||||
inherentRiskScore: score,
|
||||
residualRiskScore: score,
|
||||
},
|
||||
},
|
||||
})
|
||||
} else {
|
||||
const newRisk: Risk = {
|
||||
id: `risk-${Date.now()}`,
|
||||
...data,
|
||||
severity,
|
||||
inherentRiskScore: score,
|
||||
residualRiskScore: score,
|
||||
status: 'IDENTIFIED',
|
||||
mitigation: [],
|
||||
owner: null,
|
||||
relatedControls: [],
|
||||
relatedRequirements: [],
|
||||
}
|
||||
addRisk(newRisk)
|
||||
}
|
||||
|
||||
setShowForm(false)
|
||||
setEditingRisk(null)
|
||||
}
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
if (confirm('Möchten Sie dieses Risiko wirklich löschen?')) {
|
||||
dispatch({ type: 'DELETE_RISK', payload: id })
|
||||
}
|
||||
}
|
||||
|
||||
const handleEdit = (risk: Risk) => {
|
||||
setEditingRisk(risk)
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
// Stats
|
||||
const totalRisks = state.risks.length
|
||||
const criticalRisks = state.risks.filter(r => r.severity === 'CRITICAL').length
|
||||
const highRisks = state.risks.filter(r => r.severity === 'HIGH').length
|
||||
const mitigatedRisks = state.risks.filter(r => r.mitigation.length > 0).length
|
||||
|
||||
const stepInfo = STEP_EXPLANATIONS['risks']
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Step Header */}
|
||||
<StepHeader
|
||||
stepId="risks"
|
||||
title={stepInfo.title}
|
||||
description={stepInfo.description}
|
||||
explanation={stepInfo.explanation}
|
||||
tips={stepInfo.tips}
|
||||
>
|
||||
{!showForm && (
|
||||
<button
|
||||
onClick={() => setShowForm(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 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
Risiko hinzufuegen
|
||||
</button>
|
||||
)}
|
||||
</StepHeader>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="text-sm text-gray-500">Gesamt</div>
|
||||
<div className="text-3xl font-bold text-gray-900">{totalRisks}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-red-200 p-6">
|
||||
<div className="text-sm text-red-600">Kritisch</div>
|
||||
<div className="text-3xl font-bold text-red-600">{criticalRisks}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-orange-200 p-6">
|
||||
<div className="text-sm text-orange-600">Hoch</div>
|
||||
<div className="text-3xl font-bold text-orange-600">{highRisks}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-green-200 p-6">
|
||||
<div className="text-sm text-green-600">Mit Mitigation</div>
|
||||
<div className="text-3xl font-bold text-green-600">{mitigatedRisks}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
{showForm && (
|
||||
<RiskForm
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={() => {
|
||||
setShowForm(false)
|
||||
setEditingRisk(null)
|
||||
}}
|
||||
initialData={editingRisk || undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Matrix */}
|
||||
<RiskMatrix risks={state.risks} onCellClick={() => {}} />
|
||||
|
||||
{/* Risk List */}
|
||||
{state.risks.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Alle Risiken</h3>
|
||||
<div className="space-y-4">
|
||||
{state.risks
|
||||
.sort((a, b) => b.inherentRiskScore - a.inherentRiskScore)
|
||||
.map(risk => (
|
||||
<RiskCard
|
||||
key={risk.id}
|
||||
risk={risk}
|
||||
onEdit={() => handleEdit(risk)}
|
||||
onDelete={() => handleDelete(risk.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{state.risks.length === 0 && !showForm && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
|
||||
<div className="w-16 h-16 mx-auto bg-orange-100 rounded-full flex items-center justify-center mb-4">
|
||||
<svg className="w-8 h-8 text-orange-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>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Keine Risiken erfasst</h3>
|
||||
<p className="mt-2 text-gray-500 max-w-md mx-auto">
|
||||
Beginnen Sie mit der Erfassung von Risiken für Ihre KI-Anwendungen.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowForm(true)}
|
||||
className="mt-6 px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
Erstes Risiko erfassen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,392 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { useSDK } from '@/lib/sdk'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
interface SecurityItem {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
type: 'vulnerability' | 'misconfiguration' | 'compliance' | 'hardening'
|
||||
severity: 'critical' | 'high' | 'medium' | 'low'
|
||||
status: 'open' | 'in-progress' | 'resolved' | 'accepted-risk'
|
||||
source: string
|
||||
cve: string | null
|
||||
cvss: number | null
|
||||
affectedAsset: string
|
||||
assignedTo: string | null
|
||||
createdAt: Date
|
||||
dueDate: Date | null
|
||||
remediation: string
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MOCK DATA
|
||||
// =============================================================================
|
||||
|
||||
const mockItems: SecurityItem[] = [
|
||||
{
|
||||
id: 'sec-001',
|
||||
title: 'SQL Injection in Login-Modul',
|
||||
description: 'Unzureichende Validierung von Benutzereingaben ermoeglicht SQL Injection',
|
||||
type: 'vulnerability',
|
||||
severity: 'critical',
|
||||
status: 'in-progress',
|
||||
source: 'Penetrationstest',
|
||||
cve: 'CVE-2024-12345',
|
||||
cvss: 9.8,
|
||||
affectedAsset: 'auth-service',
|
||||
assignedTo: 'Entwicklung',
|
||||
createdAt: new Date('2024-01-15'),
|
||||
dueDate: new Date('2024-01-25'),
|
||||
remediation: 'Parameterisierte Queries verwenden, Input-Validierung implementieren',
|
||||
},
|
||||
{
|
||||
id: 'sec-002',
|
||||
title: 'Veraltete TLS-Version',
|
||||
description: 'Server unterstuetzt noch TLS 1.0 und 1.1',
|
||||
type: 'misconfiguration',
|
||||
severity: 'high',
|
||||
status: 'open',
|
||||
source: 'Vulnerability Scanner',
|
||||
cve: null,
|
||||
cvss: 7.5,
|
||||
affectedAsset: 'web-server',
|
||||
assignedTo: null,
|
||||
createdAt: new Date('2024-01-18'),
|
||||
dueDate: new Date('2024-02-01'),
|
||||
remediation: 'TLS 1.2 als Minimum konfigurieren, TLS 1.3 bevorzugen',
|
||||
},
|
||||
{
|
||||
id: 'sec-003',
|
||||
title: 'Fehlende Content-Security-Policy',
|
||||
description: 'HTTP-Header CSP nicht konfiguriert',
|
||||
type: 'hardening',
|
||||
severity: 'medium',
|
||||
status: 'open',
|
||||
source: 'Security Audit',
|
||||
cve: null,
|
||||
cvss: 5.4,
|
||||
affectedAsset: 'website',
|
||||
assignedTo: 'DevOps',
|
||||
createdAt: new Date('2024-01-10'),
|
||||
dueDate: new Date('2024-02-15'),
|
||||
remediation: 'Strikte CSP-Header implementieren',
|
||||
},
|
||||
{
|
||||
id: 'sec-004',
|
||||
title: 'Unsichere Cookie-Konfiguration',
|
||||
description: 'Session-Cookies ohne Secure und HttpOnly Flags',
|
||||
type: 'misconfiguration',
|
||||
severity: 'medium',
|
||||
status: 'resolved',
|
||||
source: 'Code Review',
|
||||
cve: null,
|
||||
cvss: 5.3,
|
||||
affectedAsset: 'auth-service',
|
||||
assignedTo: 'Entwicklung',
|
||||
createdAt: new Date('2024-01-05'),
|
||||
dueDate: new Date('2024-01-15'),
|
||||
remediation: 'Cookie-Flags setzen: Secure, HttpOnly, SameSite',
|
||||
},
|
||||
{
|
||||
id: 'sec-005',
|
||||
title: 'Veraltete Abhaengigkeit lodash',
|
||||
description: 'Bekannte Schwachstelle in lodash < 4.17.21',
|
||||
type: 'vulnerability',
|
||||
severity: 'high',
|
||||
status: 'in-progress',
|
||||
source: 'SBOM Scan',
|
||||
cve: 'CVE-2021-23337',
|
||||
cvss: 7.2,
|
||||
affectedAsset: 'frontend-app',
|
||||
assignedTo: 'Entwicklung',
|
||||
createdAt: new Date('2024-01-20'),
|
||||
dueDate: new Date('2024-01-30'),
|
||||
remediation: 'Abhaengigkeit auf Version 4.17.21 oder hoeher aktualisieren',
|
||||
},
|
||||
{
|
||||
id: 'sec-006',
|
||||
title: 'Fehlende Verschluesselung at Rest',
|
||||
description: 'Datenbank-Backup ohne Verschluesselung',
|
||||
type: 'compliance',
|
||||
severity: 'high',
|
||||
status: 'accepted-risk',
|
||||
source: 'Compliance Audit',
|
||||
cve: null,
|
||||
cvss: null,
|
||||
affectedAsset: 'database-backup',
|
||||
assignedTo: 'IT Operations',
|
||||
createdAt: new Date('2024-01-08'),
|
||||
dueDate: null,
|
||||
remediation: 'Backup-Verschluesselung aktivieren (AES-256)',
|
||||
},
|
||||
]
|
||||
|
||||
// =============================================================================
|
||||
// COMPONENTS
|
||||
// =============================================================================
|
||||
|
||||
function SecurityItemCard({ item }: { item: SecurityItem }) {
|
||||
const typeLabels = {
|
||||
vulnerability: 'Schwachstelle',
|
||||
misconfiguration: 'Fehlkonfiguration',
|
||||
compliance: 'Compliance',
|
||||
hardening: 'Haertung',
|
||||
}
|
||||
|
||||
const typeColors = {
|
||||
vulnerability: 'bg-red-100 text-red-700',
|
||||
misconfiguration: 'bg-orange-100 text-orange-700',
|
||||
compliance: 'bg-purple-100 text-purple-700',
|
||||
hardening: 'bg-blue-100 text-blue-700',
|
||||
}
|
||||
|
||||
const severityColors = {
|
||||
critical: 'bg-red-500 text-white',
|
||||
high: 'bg-orange-500 text-white',
|
||||
medium: 'bg-yellow-500 text-white',
|
||||
low: 'bg-green-500 text-white',
|
||||
}
|
||||
|
||||
const statusColors = {
|
||||
open: 'bg-blue-100 text-blue-700',
|
||||
'in-progress': 'bg-yellow-100 text-yellow-700',
|
||||
resolved: 'bg-green-100 text-green-700',
|
||||
'accepted-risk': 'bg-gray-100 text-gray-600',
|
||||
}
|
||||
|
||||
const statusLabels = {
|
||||
open: 'Offen',
|
||||
'in-progress': 'In Bearbeitung',
|
||||
resolved: 'Behoben',
|
||||
'accepted-risk': 'Akzeptiert',
|
||||
}
|
||||
|
||||
const isOverdue = item.dueDate && item.dueDate < new Date() && item.status !== 'resolved'
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-xl border-2 p-6 ${
|
||||
item.severity === 'critical' && item.status !== 'resolved' ? 'border-red-300' :
|
||||
isOverdue ? 'border-orange-300' :
|
||||
item.status === 'resolved' ? 'border-green-200' : 'border-gray-200'
|
||||
}`}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${severityColors[item.severity]}`}>
|
||||
{item.severity.toUpperCase()}
|
||||
</span>
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${typeColors[item.type]}`}>
|
||||
{typeLabels[item.type]}
|
||||
</span>
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${statusColors[item.status]}`}>
|
||||
{statusLabels[item.status]}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">{item.title}</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">{item.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500">Betroffenes Asset: </span>
|
||||
<span className="font-medium text-gray-700">{item.affectedAsset}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Quelle: </span>
|
||||
<span className="font-medium text-gray-700">{item.source}</span>
|
||||
</div>
|
||||
{item.cve && (
|
||||
<div>
|
||||
<span className="text-gray-500">CVE: </span>
|
||||
<span className="font-mono text-gray-700">{item.cve}</span>
|
||||
</div>
|
||||
)}
|
||||
{item.cvss && (
|
||||
<div>
|
||||
<span className="text-gray-500">CVSS: </span>
|
||||
<span className={`font-bold ${
|
||||
item.cvss >= 9 ? 'text-red-600' :
|
||||
item.cvss >= 7 ? 'text-orange-600' :
|
||||
item.cvss >= 4 ? 'text-yellow-600' : 'text-green-600'
|
||||
}`}>{item.cvss}</span>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="text-gray-500">Zugewiesen: </span>
|
||||
<span className="font-medium text-gray-700">{item.assignedTo || 'Nicht zugewiesen'}</span>
|
||||
</div>
|
||||
{item.dueDate && (
|
||||
<div className={isOverdue ? 'text-red-600' : ''}>
|
||||
<span className="text-gray-500">Frist: </span>
|
||||
<span className="font-medium">
|
||||
{item.dueDate.toLocaleDateString('de-DE')}
|
||||
{isOverdue && ' (ueberfaellig)'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 p-3 bg-gray-50 rounded-lg">
|
||||
<span className="text-sm text-gray-500">Empfohlene Massnahme: </span>
|
||||
<span className="text-sm text-gray-700">{item.remediation}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between">
|
||||
<span className="text-xs text-gray-500">
|
||||
Erstellt: {item.createdAt.toLocaleDateString('de-DE')}
|
||||
</span>
|
||||
{item.status !== 'resolved' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="px-3 py-1 text-sm text-purple-600 hover:bg-purple-50 rounded-lg transition-colors">
|
||||
Bearbeiten
|
||||
</button>
|
||||
<button className="px-3 py-1 text-sm bg-green-50 text-green-700 hover:bg-green-100 rounded-lg transition-colors">
|
||||
Als behoben markieren
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN PAGE
|
||||
// =============================================================================
|
||||
|
||||
export default function SecurityBacklogPage() {
|
||||
const { state } = useSDK()
|
||||
const [items] = useState<SecurityItem[]>(mockItems)
|
||||
const [filter, setFilter] = useState<string>('all')
|
||||
|
||||
const filteredItems = filter === 'all'
|
||||
? items
|
||||
: items.filter(i => i.severity === filter || i.status === filter || i.type === filter)
|
||||
|
||||
const openItems = items.filter(i => i.status === 'open').length
|
||||
const criticalCount = items.filter(i => i.severity === 'critical' && i.status !== 'resolved').length
|
||||
const highCount = items.filter(i => i.severity === 'high' && i.status !== 'resolved').length
|
||||
const overdueCount = items.filter(i =>
|
||||
i.dueDate && i.dueDate < new Date() && i.status !== 'resolved'
|
||||
).length
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Security Backlog</h1>
|
||||
<p className="mt-1 text-gray-500">
|
||||
Verwalten Sie Sicherheitsbefunde und verfolgen Sie deren Behebung
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
SBOM importieren
|
||||
</button>
|
||||
<button 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>
|
||||
Befund erfassen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="text-sm text-gray-500">Offen</div>
|
||||
<div className="text-3xl font-bold text-gray-900">{openItems}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-red-200 p-6">
|
||||
<div className="text-sm text-red-600">Kritisch</div>
|
||||
<div className="text-3xl font-bold text-red-600">{criticalCount}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-orange-200 p-6">
|
||||
<div className="text-sm text-orange-600">Hoch</div>
|
||||
<div className="text-3xl font-bold text-orange-600">{highCount}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-yellow-200 p-6">
|
||||
<div className="text-sm text-yellow-600">Ueberfaellig</div>
|
||||
<div className="text-3xl font-bold text-yellow-600">{overdueCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Critical Alert */}
|
||||
{criticalCount > 0 && (
|
||||
<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">
|
||||
<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>
|
||||
<h4 className="font-medium text-red-800">{criticalCount} kritische Schwachstelle(n) erfordern sofortige Aufmerksamkeit</h4>
|
||||
<p className="text-sm text-red-600">
|
||||
Diese Befunde haben ein CVSS von 9.0 oder hoeher und sollten priorisiert werden.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filter */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm text-gray-500">Filter:</span>
|
||||
{['all', 'open', 'in-progress', 'critical', 'high', 'vulnerability', 'misconfiguration'].map(f => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
className={`px-3 py-1 text-sm rounded-full transition-colors ${
|
||||
filter === f
|
||||
? 'bg-purple-600 text-white'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{f === 'all' ? 'Alle' :
|
||||
f === 'open' ? 'Offen' :
|
||||
f === 'in-progress' ? 'In Bearbeitung' :
|
||||
f === 'critical' ? 'Kritisch' :
|
||||
f === 'high' ? 'Hoch' :
|
||||
f === 'vulnerability' ? 'Schwachstellen' : 'Fehlkonfigurationen'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Items List */}
|
||||
<div className="space-y-4">
|
||||
{filteredItems
|
||||
.sort((a, b) => {
|
||||
// Sort by severity and status
|
||||
const severityOrder = { critical: 0, high: 1, medium: 2, low: 3 }
|
||||
const statusOrder = { open: 0, 'in-progress': 1, 'accepted-risk': 2, resolved: 3 }
|
||||
const severityDiff = severityOrder[a.severity] - severityOrder[b.severity]
|
||||
if (severityDiff !== 0) return severityDiff
|
||||
return statusOrder[a.status] - statusOrder[b.status]
|
||||
})
|
||||
.map(item => (
|
||||
<SecurityItemCard key={item.id} item={item} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredItems.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-green-100 rounded-full flex items-center justify-center mb-4">
|
||||
<svg className="w-8 h-8 text-green-600" 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>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Keine Befunde gefunden</h3>
|
||||
<p className="mt-2 text-gray-500">Passen Sie den Filter an oder fuehren Sie einen neuen Scan durch.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Source Policy Management Page (SDK Version)
|
||||
*
|
||||
* Whitelist-based data source management for edu-search-service.
|
||||
* For auditors: Full audit trail for all changes.
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useSDK } from '@/lib/sdk'
|
||||
import StepHeader from '@/components/sdk/StepHeader/StepHeader'
|
||||
import { SourcesTab } from '@/components/sdk/source-policy/SourcesTab'
|
||||
import { OperationsMatrixTab } from '@/components/sdk/source-policy/OperationsMatrixTab'
|
||||
import { PIIRulesTab } from '@/components/sdk/source-policy/PIIRulesTab'
|
||||
import { AuditTab } from '@/components/sdk/source-policy/AuditTab'
|
||||
|
||||
// API base URL for edu-search-service
|
||||
const getApiBase = () => {
|
||||
if (typeof window === 'undefined') return 'http://localhost:8088'
|
||||
const hostname = window.location.hostname
|
||||
if (hostname === 'localhost' || hostname === '127.0.0.1') {
|
||||
return 'http://localhost:8088'
|
||||
}
|
||||
return `https://${hostname}:8089`
|
||||
}
|
||||
|
||||
interface PolicyStats {
|
||||
active_policies: number
|
||||
allowed_sources: number
|
||||
pii_rules: number
|
||||
blocked_today: number
|
||||
blocked_total: number
|
||||
}
|
||||
|
||||
type TabId = 'dashboard' | 'sources' | 'operations' | 'pii' | 'audit'
|
||||
|
||||
export default function SourcePolicyPage() {
|
||||
const { state } = useSDK()
|
||||
const [activeTab, setActiveTab] = useState<TabId>('dashboard')
|
||||
const [stats, setStats] = useState<PolicyStats | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [apiBase, setApiBase] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const base = getApiBase()
|
||||
setApiBase(base)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (apiBase !== null) {
|
||||
fetchStats()
|
||||
}
|
||||
}, [apiBase])
|
||||
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const res = await fetch(`${apiBase}/v1/admin/policy-stats`)
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error('Fehler beim Laden der Statistiken')
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
setStats(data)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
|
||||
setStats({
|
||||
active_policies: 0,
|
||||
allowed_sources: 0,
|
||||
pii_rules: 0,
|
||||
blocked_today: 0,
|
||||
blocked_total: 0,
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const tabs: { id: TabId; name: string; icon: JSX.Element }[] = [
|
||||
{
|
||||
id: 'dashboard',
|
||||
name: 'Dashboard',
|
||||
icon: (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'sources',
|
||||
name: 'Quellen',
|
||||
icon: (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'operations',
|
||||
name: 'Operations',
|
||||
icon: (
|
||||
<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>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'pii',
|
||||
name: 'PII-Regeln',
|
||||
icon: (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'audit',
|
||||
name: 'Audit',
|
||||
icon: (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<StepHeader stepId="source-policy" showProgress={true} />
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center justify-between">
|
||||
<span>{error}</span>
|
||||
<button onClick={() => setError(null)} className="text-red-500 hover:text-red-700">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats Cards */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<div className="text-2xl font-bold text-purple-600">{stats.active_policies}</div>
|
||||
<div className="text-sm text-slate-500">Aktive Policies</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<div className="text-2xl font-bold text-green-600">{stats.allowed_sources}</div>
|
||||
<div className="text-sm text-slate-500">Zugelassene Quellen</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<div className="text-2xl font-bold text-red-600">{stats.blocked_today}</div>
|
||||
<div className="text-sm text-slate-500">Blockiert (heute)</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<div className="text-2xl font-bold text-blue-600">{stats.pii_rules}</div>
|
||||
<div className="text-sm text-slate-500">PII-Regeln</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`px-4 py-2 rounded-lg font-medium transition-colors flex items-center gap-2 ${
|
||||
activeTab === tab.id
|
||||
? 'bg-purple-600 text-white'
|
||||
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
{tab.icon}
|
||||
{tab.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
{apiBase === null ? (
|
||||
<div className="text-center py-12 text-slate-500">Initialisiere...</div>
|
||||
) : (
|
||||
<>
|
||||
{activeTab === 'dashboard' && (
|
||||
<div className="text-center py-12 text-slate-500">
|
||||
{loading ? 'Lade Dashboard...' : 'Dashboard-Ansicht - Wechseln Sie zu einem Tab fuer Details.'}
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'sources' && <SourcesTab apiBase={apiBase} onUpdate={fetchStats} />}
|
||||
{activeTab === 'operations' && <OperationsMatrixTab apiBase={apiBase} />}
|
||||
{activeTab === 'pii' && <PIIRulesTab apiBase={apiBase} onUpdate={fetchStats} />}
|
||||
{activeTab === 'audit' && <AuditTab apiBase={apiBase} />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,607 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
getModules, getMatrix, getAssignments, getStats, getDeadlines, getModuleMedia,
|
||||
getAuditLog, generateContent, generateQuiz,
|
||||
publishContent, checkEscalation, getContent,
|
||||
generateAllContent, generateAllQuizzes,
|
||||
} from '@/lib/sdk/training/api'
|
||||
import type {
|
||||
TrainingModule, TrainingAssignment,
|
||||
MatrixResponse, TrainingStats, DeadlineInfo, AuditLogEntry, ModuleContent, TrainingMedia, VideoScript,
|
||||
} from '@/lib/sdk/training/types'
|
||||
import {
|
||||
REGULATION_LABELS, REGULATION_COLORS, FREQUENCY_LABELS,
|
||||
STATUS_LABELS, STATUS_COLORS, ROLE_LABELS, ALL_ROLES,
|
||||
} from '@/lib/sdk/training/types'
|
||||
import AudioPlayer from '@/components/training/AudioPlayer'
|
||||
import VideoPlayer from '@/components/training/VideoPlayer'
|
||||
import ScriptPreview from '@/components/training/ScriptPreview'
|
||||
type Tab = 'overview' | 'modules' | 'matrix' | 'assignments' | 'content' | 'audit'
|
||||
|
||||
export default function TrainingPage() {
|
||||
const [activeTab, setActiveTab] = useState<Tab>('overview')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const [stats, setStats] = useState<TrainingStats | null>(null)
|
||||
const [modules, setModules] = useState<TrainingModule[]>([])
|
||||
const [matrix, setMatrix] = useState<MatrixResponse | null>(null)
|
||||
const [assignments, setAssignments] = useState<TrainingAssignment[]>([])
|
||||
const [deadlines, setDeadlines] = useState<DeadlineInfo[]>([])
|
||||
const [auditLog, setAuditLog] = useState<AuditLogEntry[]>([])
|
||||
|
||||
const [selectedModuleId, setSelectedModuleId] = useState<string>('')
|
||||
const [generatedContent, setGeneratedContent] = useState<ModuleContent | null>(null)
|
||||
const [generating, setGenerating] = useState(false)
|
||||
const [bulkGenerating, setBulkGenerating] = useState(false)
|
||||
const [bulkResult, setBulkResult] = useState<{ generated: number; skipped: number; errors: string[] } | null>(null)
|
||||
const [moduleMedia, setModuleMedia] = useState<TrainingMedia[]>([])
|
||||
|
||||
const [statusFilter, setStatusFilter] = useState<string>('')
|
||||
const [regulationFilter, setRegulationFilter] = useState<string>('')
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [])
|
||||
|
||||
async function loadData() {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const [statsRes, modulesRes, matrixRes, assignmentsRes, deadlinesRes, auditRes] = await Promise.allSettled([
|
||||
getStats(),
|
||||
getModules(),
|
||||
getMatrix(),
|
||||
getAssignments({ limit: 50 }),
|
||||
getDeadlines(10),
|
||||
getAuditLog({ limit: 30 }),
|
||||
])
|
||||
|
||||
if (statsRes.status === 'fulfilled') setStats(statsRes.value)
|
||||
if (modulesRes.status === 'fulfilled') setModules(modulesRes.value.modules)
|
||||
if (matrixRes.status === 'fulfilled') setMatrix(matrixRes.value)
|
||||
if (assignmentsRes.status === 'fulfilled') setAssignments(assignmentsRes.value.assignments)
|
||||
if (deadlinesRes.status === 'fulfilled') setDeadlines(deadlinesRes.value.deadlines)
|
||||
if (auditRes.status === 'fulfilled') setAuditLog(auditRes.value.entries)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGenerateContent() {
|
||||
if (!selectedModuleId) return
|
||||
setGenerating(true)
|
||||
try {
|
||||
const content = await generateContent(selectedModuleId)
|
||||
setGeneratedContent(content)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler bei der Content-Generierung')
|
||||
} finally {
|
||||
setGenerating(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGenerateQuiz() {
|
||||
if (!selectedModuleId) return
|
||||
setGenerating(true)
|
||||
try {
|
||||
await generateQuiz(selectedModuleId, 5)
|
||||
await loadData()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler bei der Quiz-Generierung')
|
||||
} finally {
|
||||
setGenerating(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePublishContent(contentId: string) {
|
||||
try {
|
||||
await publishContent(contentId)
|
||||
setGeneratedContent(null)
|
||||
await loadData()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Veroeffentlichen')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCheckEscalation() {
|
||||
try {
|
||||
const result = await checkEscalation()
|
||||
alert(`Eskalation geprueft: ${result.total_checked} geprueft, ${result.escalated} eskaliert`)
|
||||
await loadData()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler bei der Eskalationspruefung')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLoadContent(moduleId: string) {
|
||||
try {
|
||||
const content = await getContent(moduleId)
|
||||
setGeneratedContent(content)
|
||||
} catch {
|
||||
setGeneratedContent(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBulkContent() {
|
||||
setBulkGenerating(true)
|
||||
setBulkResult(null)
|
||||
try {
|
||||
const result = await generateAllContent('de')
|
||||
setBulkResult({ generated: result.generated ?? 0, skipped: result.skipped ?? 0, errors: result.errors ?? [] })
|
||||
await loadData()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler bei der Bulk-Generierung')
|
||||
} finally {
|
||||
setBulkGenerating(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadModuleMedia(moduleId: string) {
|
||||
try {
|
||||
const result = await getModuleMedia(moduleId)
|
||||
setModuleMedia(result.media)
|
||||
} catch {
|
||||
setModuleMedia([])
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBulkQuiz() {
|
||||
setBulkGenerating(true)
|
||||
setBulkResult(null)
|
||||
try {
|
||||
const result = await generateAllQuizzes()
|
||||
setBulkResult({ generated: result.generated ?? 0, skipped: result.skipped ?? 0, errors: result.errors ?? [] })
|
||||
await loadData()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler bei der Bulk-Quiz-Generierung')
|
||||
} finally {
|
||||
setBulkGenerating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const tabs: { id: Tab; label: string }[] = [
|
||||
{ id: 'overview', label: 'Uebersicht' },
|
||||
{ id: 'modules', label: 'Modulkatalog' },
|
||||
{ id: 'matrix', label: 'Training Matrix' },
|
||||
{ id: 'assignments', label: 'Zuweisungen' },
|
||||
{ id: 'content', label: 'Content-Generator' },
|
||||
{ id: 'audit', label: 'Audit Trail' },
|
||||
]
|
||||
|
||||
const filteredModules = modules.filter(m =>
|
||||
(!regulationFilter || m.regulation_area === regulationFilter)
|
||||
)
|
||||
|
||||
const filteredAssignments = assignments.filter(a =>
|
||||
(!statusFilter || a.status === statusFilter)
|
||||
)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-8 bg-gray-200 rounded w-1/3"></div>
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{[1,2,3,4].map(i => <div key={i} className="h-24 bg-gray-200 rounded"></div>)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Compliance Training Engine</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Training-Module, Zuweisungen und Compliance-Schulungen verwalten
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCheckEscalation}
|
||||
className="px-4 py-2 text-sm bg-orange-50 text-orange-700 border border-orange-200 rounded-lg hover:bg-orange-100"
|
||||
>
|
||||
Eskalation pruefen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg text-sm">
|
||||
{error}
|
||||
<button onClick={() => setError(null)} className="ml-2 underline">Schliessen</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-gray-200">
|
||||
<nav className="flex -mb-px space-x-6">
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`pb-3 px-1 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
{activeTab === 'overview' && stats && (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<KPICard label="Module" value={stats.total_modules} />
|
||||
<KPICard label="Zuweisungen" value={stats.total_assignments} />
|
||||
<KPICard label="Abschlussrate" value={`${stats.completion_rate.toFixed(1)}%`} color={stats.completion_rate >= 80 ? 'green' : stats.completion_rate >= 50 ? 'yellow' : 'red'} />
|
||||
<KPICard label="Ueberfaellig" value={stats.overdue_count} color={stats.overdue_count > 0 ? 'red' : 'green'} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<KPICard label="Ausstehend" value={stats.pending_count} />
|
||||
<KPICard label="In Bearbeitung" value={stats.in_progress_count} />
|
||||
<KPICard label="Avg. Quiz-Score" value={`${stats.avg_quiz_score.toFixed(1)}%`} />
|
||||
<KPICard label="Deadlines (7d)" value={stats.upcoming_deadlines} color={stats.upcoming_deadlines > 5 ? 'yellow' : 'green'} />
|
||||
</div>
|
||||
|
||||
{/* Status Bar */}
|
||||
<div className="bg-white border rounded-lg p-4">
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-3">Status-Verteilung</h3>
|
||||
{stats.total_assignments > 0 && (
|
||||
<div className="flex gap-1 h-6 rounded-full overflow-hidden bg-gray-100">
|
||||
{stats.completed_count > 0 && <div className="bg-green-500" style={{ width: `${(stats.completed_count / stats.total_assignments) * 100}%` }} title={`Abgeschlossen: ${stats.completed_count}`} />}
|
||||
{stats.in_progress_count > 0 && <div className="bg-blue-500" style={{ width: `${(stats.in_progress_count / stats.total_assignments) * 100}%` }} title={`In Bearbeitung: ${stats.in_progress_count}`} />}
|
||||
{stats.pending_count > 0 && <div className="bg-gray-400" style={{ width: `${(stats.pending_count / stats.total_assignments) * 100}%` }} title={`Ausstehend: ${stats.pending_count}`} />}
|
||||
{stats.overdue_count > 0 && <div className="bg-red-500" style={{ width: `${(stats.overdue_count / stats.total_assignments) * 100}%` }} title={`Ueberfaellig: ${stats.overdue_count}`} />}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-4 mt-2 text-xs text-gray-500">
|
||||
<span className="flex items-center gap-1"><span className="w-3 h-3 rounded-full bg-green-500 inline-block" /> Abgeschlossen</span>
|
||||
<span className="flex items-center gap-1"><span className="w-3 h-3 rounded-full bg-blue-500 inline-block" /> In Bearbeitung</span>
|
||||
<span className="flex items-center gap-1"><span className="w-3 h-3 rounded-full bg-gray-400 inline-block" /> Ausstehend</span>
|
||||
<span className="flex items-center gap-1"><span className="w-3 h-3 rounded-full bg-red-500 inline-block" /> Ueberfaellig</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Deadlines */}
|
||||
{deadlines.length > 0 && (
|
||||
<div className="bg-white border rounded-lg p-4">
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-3">Naechste Deadlines</h3>
|
||||
<div className="space-y-2">
|
||||
{deadlines.slice(0, 5).map(d => (
|
||||
<div key={d.assignment_id} className="flex items-center justify-between text-sm">
|
||||
<div>
|
||||
<span className="font-medium">{d.module_title}</span>
|
||||
<span className="text-gray-500 ml-2">({d.user_name})</span>
|
||||
</div>
|
||||
<span className={`px-2 py-0.5 rounded text-xs ${
|
||||
d.days_left <= 0 ? 'bg-red-100 text-red-700' :
|
||||
d.days_left <= 7 ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{d.days_left <= 0 ? `${Math.abs(d.days_left)} Tage ueberfaellig` : `${d.days_left} Tage`}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'modules' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-3">
|
||||
<select value={regulationFilter} onChange={e => setRegulationFilter(e.target.value)} className="px-3 py-1.5 text-sm border rounded-lg bg-white">
|
||||
<option value="">Alle Bereiche</option>
|
||||
{Object.entries(REGULATION_LABELS).map(([key, label]) => (
|
||||
<option key={key} value={key}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filteredModules.map(m => (
|
||||
<div key={m.id} className="bg-white border rounded-lg p-4 hover:shadow-sm transition-shadow">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<span className={`inline-block px-2 py-0.5 rounded text-xs font-medium ${REGULATION_COLORS[m.regulation_area]?.bg || 'bg-gray-100'} ${REGULATION_COLORS[m.regulation_area]?.text || 'text-gray-700'}`}>
|
||||
{REGULATION_LABELS[m.regulation_area] || m.regulation_area}
|
||||
</span>
|
||||
<h3 className="mt-2 font-medium text-gray-900">{m.title}</h3>
|
||||
<p className="text-xs text-gray-500 mt-0.5">{m.module_code}</p>
|
||||
</div>
|
||||
{m.nis2_relevant && (
|
||||
<span className="text-xs px-1.5 py-0.5 bg-purple-100 text-purple-700 rounded">NIS2</span>
|
||||
)}
|
||||
</div>
|
||||
{m.description && <p className="text-sm text-gray-600 mt-2 line-clamp-2">{m.description}</p>}
|
||||
<div className="flex items-center gap-3 mt-3 text-xs text-gray-500">
|
||||
<span>{m.duration_minutes} Min.</span>
|
||||
<span>{FREQUENCY_LABELS[m.frequency_type]}</span>
|
||||
<span>Quiz: {m.pass_threshold}%</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{filteredModules.length === 0 && <p className="text-center text-gray-500 py-8">Keine Module gefunden</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'matrix' && matrix && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-600">Compliance Training Matrix (CTM): Welche Rollen benoetigen welche Schulungsmodule</p>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-gray-50">
|
||||
<th className="text-left p-2 border font-medium text-gray-700 w-48">Rolle</th>
|
||||
<th className="text-left p-2 border font-medium text-gray-700">Module</th>
|
||||
<th className="text-center p-2 border font-medium text-gray-700 w-20">Anzahl</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ALL_ROLES.map(role => {
|
||||
const entries = matrix.entries[role] || []
|
||||
return (
|
||||
<tr key={role} className="border-b hover:bg-gray-50">
|
||||
<td className="p-2 border font-medium">
|
||||
<span className="text-gray-900">{role}</span>
|
||||
<span className="text-gray-500 ml-1 text-xs">{ROLE_LABELS[role]}</span>
|
||||
</td>
|
||||
<td className="p-2 border">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{entries.map(e => (
|
||||
<span key={e.id || e.module_id} className={`inline-block px-2 py-0.5 rounded text-xs ${e.is_mandatory ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-600'}`} title={`${e.module_title} (${e.is_mandatory ? 'Pflicht' : 'Optional'})`}>
|
||||
{e.module_code}
|
||||
</span>
|
||||
))}
|
||||
{entries.length === 0 && <span className="text-gray-400 text-xs">Keine Module</span>}
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-2 border text-center">{entries.length}</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'assignments' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-3">
|
||||
<select value={statusFilter} onChange={e => setStatusFilter(e.target.value)} className="px-3 py-1.5 text-sm border rounded-lg bg-white">
|
||||
<option value="">Alle Status</option>
|
||||
{Object.entries(STATUS_LABELS).map(([key, label]) => (
|
||||
<option key={key} value={key}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-gray-50">
|
||||
<th className="text-left p-2 border font-medium text-gray-700">Modul</th>
|
||||
<th className="text-left p-2 border font-medium text-gray-700">Mitarbeiter</th>
|
||||
<th className="text-left p-2 border font-medium text-gray-700">Rolle</th>
|
||||
<th className="text-center p-2 border font-medium text-gray-700">Fortschritt</th>
|
||||
<th className="text-center p-2 border font-medium text-gray-700">Status</th>
|
||||
<th className="text-center p-2 border font-medium text-gray-700">Quiz</th>
|
||||
<th className="text-left p-2 border font-medium text-gray-700">Deadline</th>
|
||||
<th className="text-center p-2 border font-medium text-gray-700">Eskalation</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredAssignments.map(a => (
|
||||
<tr key={a.id} className="border-b hover:bg-gray-50">
|
||||
<td className="p-2 border">
|
||||
<div className="font-medium">{a.module_title || a.module_code}</div>
|
||||
<div className="text-xs text-gray-500">{a.module_code}</div>
|
||||
</td>
|
||||
<td className="p-2 border">
|
||||
<div>{a.user_name}</div>
|
||||
<div className="text-xs text-gray-500">{a.user_email}</div>
|
||||
</td>
|
||||
<td className="p-2 border text-xs">{a.role_code || '-'}</td>
|
||||
<td className="p-2 border text-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 bg-gray-200 rounded-full h-2">
|
||||
<div className="bg-blue-500 h-2 rounded-full" style={{ width: `${a.progress_percent}%` }} />
|
||||
</div>
|
||||
<span className="text-xs w-8">{a.progress_percent}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-2 border text-center">
|
||||
<span className={`px-2 py-0.5 rounded text-xs ${STATUS_COLORS[a.status]?.bg || ''} ${STATUS_COLORS[a.status]?.text || ''}`}>
|
||||
{STATUS_LABELS[a.status] || a.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-2 border text-center text-xs">
|
||||
{a.quiz_score != null ? (
|
||||
<span className={`font-medium ${a.quiz_passed ? 'text-green-600' : 'text-red-600'}`}>{a.quiz_score.toFixed(0)}%</span>
|
||||
) : '-'}
|
||||
</td>
|
||||
<td className="p-2 border text-xs">{new Date(a.deadline).toLocaleDateString('de-DE')}</td>
|
||||
<td className="p-2 border text-center">
|
||||
{a.escalation_level > 0 ? <span className="px-2 py-0.5 rounded text-xs bg-red-100 text-red-700">L{a.escalation_level}</span> : '-'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{filteredAssignments.length === 0 && <p className="text-center text-gray-500 py-8">Keine Zuweisungen</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'content' && (
|
||||
<div className="space-y-6">
|
||||
{/* Bulk Generation */}
|
||||
<div className="bg-white border rounded-lg p-4">
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-3">Bulk-Generierung</h3>
|
||||
<p className="text-xs text-gray-500 mb-4">Generiere Inhalte und Quiz-Fragen fuer alle Module auf einmal</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleBulkContent}
|
||||
disabled={bulkGenerating}
|
||||
className="px-4 py-2 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50"
|
||||
>
|
||||
{bulkGenerating ? 'Generiere...' : 'Alle Inhalte generieren'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleBulkQuiz}
|
||||
disabled={bulkGenerating}
|
||||
className="px-4 py-2 text-sm bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50"
|
||||
>
|
||||
{bulkGenerating ? 'Generiere...' : 'Alle Quizfragen generieren'}
|
||||
</button>
|
||||
</div>
|
||||
{bulkResult && (
|
||||
<div className="mt-4 p-3 bg-gray-50 rounded-lg text-sm">
|
||||
<div className="flex gap-6">
|
||||
<span className="text-green-700">Generiert: {bulkResult.generated}</span>
|
||||
<span className="text-gray-500">Uebersprungen: {bulkResult.skipped}</span>
|
||||
{bulkResult.errors?.length > 0 && (
|
||||
<span className="text-red-600">Fehler: {bulkResult.errors.length}</span>
|
||||
)}
|
||||
</div>
|
||||
{bulkResult.errors?.length > 0 && (
|
||||
<div className="mt-2 text-xs text-red-600">
|
||||
{bulkResult.errors.map((err, i) => <div key={i}>{err}</div>)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-white border rounded-lg p-4">
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-3">LLM-Content-Generator</h3>
|
||||
<p className="text-xs text-gray-500 mb-4">Generiere Schulungsinhalte und Quiz-Fragen automatisch via KI</p>
|
||||
<div className="flex gap-3 items-end">
|
||||
<div className="flex-1">
|
||||
<label className="text-xs text-gray-600 block mb-1">Modul auswaehlen</label>
|
||||
<select
|
||||
value={selectedModuleId}
|
||||
onChange={e => { setSelectedModuleId(e.target.value); setGeneratedContent(null); setModuleMedia([]); if (e.target.value) { handleLoadContent(e.target.value); loadModuleMedia(e.target.value); } }}
|
||||
className="w-full px-3 py-2 text-sm border rounded-lg bg-white"
|
||||
>
|
||||
<option value="">Modul waehlen...</option>
|
||||
{modules.map(m => <option key={m.id} value={m.id}>{m.module_code} - {m.title}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<button onClick={handleGenerateContent} disabled={!selectedModuleId || generating} className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50">
|
||||
{generating ? 'Generiere...' : 'Inhalt generieren'}
|
||||
</button>
|
||||
<button onClick={handleGenerateQuiz} disabled={!selectedModuleId || generating} className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50">
|
||||
{generating ? 'Generiere...' : 'Quiz generieren'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{generatedContent && (
|
||||
<div className="bg-white border rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-700">Generierter Inhalt (v{generatedContent.version})</h3>
|
||||
<p className="text-xs text-gray-500">Generiert von: {generatedContent.generated_by} ({generatedContent.llm_model})</p>
|
||||
</div>
|
||||
{!generatedContent.is_published ? (
|
||||
<button onClick={() => handlePublishContent(generatedContent.id)} className="px-3 py-1.5 text-xs bg-green-600 text-white rounded hover:bg-green-700">Veroeffentlichen</button>
|
||||
) : (
|
||||
<span className="px-3 py-1.5 text-xs bg-green-100 text-green-700 rounded">Veroeffentlicht</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="prose prose-sm max-w-none border rounded p-4 bg-gray-50 max-h-96 overflow-y-auto">
|
||||
<pre className="whitespace-pre-wrap text-sm text-gray-800">{generatedContent.content_body}</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Audio Player */}
|
||||
{selectedModuleId && generatedContent?.is_published && (
|
||||
<AudioPlayer
|
||||
moduleId={selectedModuleId}
|
||||
audio={moduleMedia.find(m => m.media_type === 'audio') || null}
|
||||
onMediaUpdate={() => loadModuleMedia(selectedModuleId)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Video Player */}
|
||||
{selectedModuleId && generatedContent?.is_published && (
|
||||
<VideoPlayer
|
||||
moduleId={selectedModuleId}
|
||||
video={moduleMedia.find(m => m.media_type === 'video') || null}
|
||||
onMediaUpdate={() => loadModuleMedia(selectedModuleId)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Script Preview */}
|
||||
{selectedModuleId && generatedContent?.is_published && (
|
||||
<ScriptPreview moduleId={selectedModuleId} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'audit' && (
|
||||
<div className="space-y-4">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-gray-50">
|
||||
<th className="text-left p-2 border font-medium text-gray-700">Zeitpunkt</th>
|
||||
<th className="text-left p-2 border font-medium text-gray-700">Aktion</th>
|
||||
<th className="text-left p-2 border font-medium text-gray-700">Entitaet</th>
|
||||
<th className="text-left p-2 border font-medium text-gray-700">Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{auditLog.map(entry => (
|
||||
<tr key={entry.id} className="border-b hover:bg-gray-50">
|
||||
<td className="p-2 border text-xs text-gray-600">{new Date(entry.created_at).toLocaleString('de-DE')}</td>
|
||||
<td className="p-2 border"><span className="px-2 py-0.5 rounded text-xs bg-gray-100 text-gray-700">{entry.action}</span></td>
|
||||
<td className="p-2 border text-xs">{entry.entity_type}</td>
|
||||
<td className="p-2 border text-xs text-gray-600">{JSON.stringify(entry.details).substring(0, 100)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{auditLog.length === 0 && <p className="text-center text-gray-500 py-8">Keine Audit-Eintraege</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function KPICard({ label, value, color }: { label: string; value: string | number; color?: string }) {
|
||||
const colorMap: Record<string, string> = {
|
||||
green: 'bg-green-50 border-green-200',
|
||||
yellow: 'bg-yellow-50 border-yellow-200',
|
||||
red: 'bg-red-50 border-red-200',
|
||||
}
|
||||
const textMap: Record<string, string> = {
|
||||
green: 'text-green-700',
|
||||
yellow: 'text-yellow-700',
|
||||
red: 'text-red-700',
|
||||
}
|
||||
return (
|
||||
<div className={`border rounded-lg p-4 ${color ? colorMap[color] || 'bg-white border-gray-200' : 'bg-white border-gray-200'}`}>
|
||||
<p className="text-xs text-gray-500">{label}</p>
|
||||
<p className={`text-2xl font-bold mt-1 ${color ? textMap[color] || 'text-gray-900' : 'text-gray-900'}`}>{value}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,733 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import {
|
||||
useVendorCompliance,
|
||||
ReportType,
|
||||
ExportFormat,
|
||||
ProcessingActivity,
|
||||
Vendor,
|
||||
} from '@/lib/sdk/vendor-compliance'
|
||||
|
||||
interface ExportConfig {
|
||||
reportType: ReportType
|
||||
format: ExportFormat
|
||||
scope: {
|
||||
vendorIds: string[]
|
||||
processingActivityIds: string[]
|
||||
includeFindings: boolean
|
||||
includeControls: boolean
|
||||
includeRiskAssessment: boolean
|
||||
dateRange?: {
|
||||
from: string
|
||||
to: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const REPORT_TYPE_META: Record<
|
||||
ReportType,
|
||||
{
|
||||
title: string
|
||||
description: string
|
||||
icon: string
|
||||
formats: ExportFormat[]
|
||||
defaultFormat: ExportFormat
|
||||
}
|
||||
> = {
|
||||
VVT_EXPORT: {
|
||||
title: 'Verarbeitungsverzeichnis (VVT)',
|
||||
description:
|
||||
'Vollständiges Verarbeitungsverzeichnis gemäß Art. 30 DSGVO mit allen Pflichtangaben',
|
||||
icon: '📋',
|
||||
formats: ['PDF', 'DOCX', 'XLSX'],
|
||||
defaultFormat: 'DOCX',
|
||||
},
|
||||
ROPA: {
|
||||
title: 'Records of Processing Activities (RoPA)',
|
||||
description:
|
||||
'Processor-Perspektive: Alle Verarbeitungen als Auftragsverarbeiter',
|
||||
icon: '📝',
|
||||
formats: ['PDF', 'DOCX', 'XLSX'],
|
||||
defaultFormat: 'DOCX',
|
||||
},
|
||||
VENDOR_AUDIT: {
|
||||
title: 'Vendor Audit Pack',
|
||||
description:
|
||||
'Vollständige Dokumentation eines Vendors inkl. Verträge, Findings und Risikobewertung',
|
||||
icon: '🔍',
|
||||
formats: ['PDF', 'DOCX'],
|
||||
defaultFormat: 'PDF',
|
||||
},
|
||||
MANAGEMENT_SUMMARY: {
|
||||
title: 'Management Summary',
|
||||
description:
|
||||
'Übersicht für die Geschäftsführung: Risiken, offene Findings, Compliance-Status',
|
||||
icon: '📊',
|
||||
formats: ['PDF', 'DOCX', 'XLSX'],
|
||||
defaultFormat: 'PDF',
|
||||
},
|
||||
DPIA_INPUT: {
|
||||
title: 'DSFA-Input',
|
||||
description:
|
||||
'Vorbereitete Daten für eine Datenschutz-Folgenabschätzung (DSFA/DPIA)',
|
||||
icon: '⚠️',
|
||||
formats: ['PDF', 'DOCX'],
|
||||
defaultFormat: 'DOCX',
|
||||
},
|
||||
}
|
||||
|
||||
const FORMAT_META: Record<ExportFormat, { label: string; icon: string }> = {
|
||||
PDF: { label: 'PDF', icon: '📄' },
|
||||
DOCX: { label: 'Word (DOCX)', icon: '📝' },
|
||||
XLSX: { label: 'Excel (XLSX)', icon: '📊' },
|
||||
JSON: { label: 'JSON', icon: '🔧' },
|
||||
}
|
||||
|
||||
export default function ReportsPage() {
|
||||
const {
|
||||
processingActivities,
|
||||
vendors,
|
||||
contracts,
|
||||
findings,
|
||||
riskAssessments,
|
||||
isLoading,
|
||||
} = useVendorCompliance()
|
||||
|
||||
const [selectedReportType, setSelectedReportType] = useState<ReportType>('VVT_EXPORT')
|
||||
const [selectedFormat, setSelectedFormat] = useState<ExportFormat>('DOCX')
|
||||
const [selectedVendors, setSelectedVendors] = useState<string[]>([])
|
||||
const [selectedActivities, setSelectedActivities] = useState<string[]>([])
|
||||
const [includeFindings, setIncludeFindings] = useState(true)
|
||||
const [includeControls, setIncludeControls] = useState(true)
|
||||
const [includeRiskAssessment, setIncludeRiskAssessment] = useState(true)
|
||||
const [isGenerating, setIsGenerating] = useState(false)
|
||||
const [generatedReports, setGeneratedReports] = useState<
|
||||
{ id: string; type: ReportType; format: ExportFormat; generatedAt: Date; filename: string }[]
|
||||
>([])
|
||||
|
||||
const reportMeta = REPORT_TYPE_META[selectedReportType]
|
||||
|
||||
// Update format when report type changes
|
||||
const handleReportTypeChange = (type: ReportType) => {
|
||||
setSelectedReportType(type)
|
||||
setSelectedFormat(REPORT_TYPE_META[type].defaultFormat)
|
||||
// Reset selections
|
||||
setSelectedVendors([])
|
||||
setSelectedActivities([])
|
||||
}
|
||||
|
||||
// Calculate statistics
|
||||
const stats = useMemo(() => {
|
||||
const openFindings = findings.filter((f) => f.status === 'OPEN').length
|
||||
const criticalFindings = findings.filter(
|
||||
(f) => f.status === 'OPEN' && f.severity === 'CRITICAL'
|
||||
).length
|
||||
const highRiskVendors = vendors.filter((v) => v.inherentRiskScore >= 70).length
|
||||
|
||||
return {
|
||||
totalActivities: processingActivities.length,
|
||||
approvedActivities: processingActivities.filter((a) => a.status === 'APPROVED').length,
|
||||
totalVendors: vendors.length,
|
||||
activeVendors: vendors.filter((v) => v.status === 'ACTIVE').length,
|
||||
totalContracts: contracts.length,
|
||||
openFindings,
|
||||
criticalFindings,
|
||||
highRiskVendors,
|
||||
}
|
||||
}, [processingActivities, vendors, contracts, findings])
|
||||
|
||||
// Handle export
|
||||
const handleExport = async () => {
|
||||
setIsGenerating(true)
|
||||
|
||||
try {
|
||||
const config: ExportConfig = {
|
||||
reportType: selectedReportType,
|
||||
format: selectedFormat,
|
||||
scope: {
|
||||
vendorIds: selectedVendors,
|
||||
processingActivityIds: selectedActivities,
|
||||
includeFindings,
|
||||
includeControls,
|
||||
includeRiskAssessment,
|
||||
},
|
||||
}
|
||||
|
||||
// Call API to generate report
|
||||
const response = await fetch('/api/sdk/v1/vendor-compliance/export', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Export fehlgeschlagen')
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
// Add to generated reports
|
||||
setGeneratedReports((prev) => [
|
||||
{
|
||||
id: result.id,
|
||||
type: selectedReportType,
|
||||
format: selectedFormat,
|
||||
generatedAt: new Date(),
|
||||
filename: result.filename,
|
||||
},
|
||||
...prev,
|
||||
])
|
||||
|
||||
// Download the file
|
||||
if (result.downloadUrl) {
|
||||
window.open(result.downloadUrl, '_blank')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Export error:', error)
|
||||
// Show error notification
|
||||
} finally {
|
||||
setIsGenerating(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle vendor selection
|
||||
const toggleVendor = (vendorId: string) => {
|
||||
setSelectedVendors((prev) =>
|
||||
prev.includes(vendorId)
|
||||
? prev.filter((id) => id !== vendorId)
|
||||
: [...prev, vendorId]
|
||||
)
|
||||
}
|
||||
|
||||
// Toggle activity selection
|
||||
const toggleActivity = (activityId: string) => {
|
||||
setSelectedActivities((prev) =>
|
||||
prev.includes(activityId)
|
||||
? prev.filter((id) => id !== activityId)
|
||||
: [...prev, activityId]
|
||||
)
|
||||
}
|
||||
|
||||
// Select all vendors
|
||||
const selectAllVendors = () => {
|
||||
setSelectedVendors(vendors.map((v) => v.id))
|
||||
}
|
||||
|
||||
// Select all activities
|
||||
const selectAllActivities = () => {
|
||||
setSelectedActivities(processingActivities.map((a) => a.id))
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
Reports & Export
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Berichte erstellen und Daten exportieren
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
label="Verarbeitungen"
|
||||
value={stats.totalActivities}
|
||||
subtext={`${stats.approvedActivities} freigegeben`}
|
||||
color="blue"
|
||||
/>
|
||||
<StatCard
|
||||
label="Vendors"
|
||||
value={stats.totalVendors}
|
||||
subtext={`${stats.highRiskVendors} hohes Risiko`}
|
||||
color="purple"
|
||||
/>
|
||||
<StatCard
|
||||
label="Offene Findings"
|
||||
value={stats.openFindings}
|
||||
subtext={`${stats.criticalFindings} kritisch`}
|
||||
color={stats.criticalFindings > 0 ? 'red' : 'yellow'}
|
||||
/>
|
||||
<StatCard
|
||||
label="Verträge"
|
||||
value={stats.totalContracts}
|
||||
subtext="dokumentiert"
|
||||
color="green"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Report Type Selection */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Report Type Cards */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Report-Typ wählen
|
||||
</h2>
|
||||
</div>
|
||||
<div className="p-4 grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{(Object.entries(REPORT_TYPE_META) as [ReportType, typeof REPORT_TYPE_META[ReportType]][]).map(
|
||||
([type, meta]) => (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => handleReportTypeChange(type)}
|
||||
className={`p-4 rounded-lg border-2 text-left transition-all ${
|
||||
selectedReportType === type
|
||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
||||
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">{meta.icon}</span>
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900 dark:text-white">
|
||||
{meta.title}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{meta.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scope Selection */}
|
||||
{(selectedReportType === 'VVT_EXPORT' || selectedReportType === 'ROPA' || selectedReportType === 'DPIA_INPUT') && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Verarbeitungen auswählen
|
||||
</h2>
|
||||
<button
|
||||
onClick={selectAllActivities}
|
||||
className="text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400"
|
||||
>
|
||||
Alle auswählen
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4 max-h-64 overflow-y-auto">
|
||||
{processingActivities.length === 0 ? (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 text-center py-4">
|
||||
Keine Verarbeitungen vorhanden
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{processingActivities.map((activity) => (
|
||||
<label
|
||||
key={activity.id}
|
||||
className="flex items-center gap-3 p-2 rounded hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedActivities.includes(activity.id)}
|
||||
onChange={() => toggleActivity(activity.id)}
|
||||
className="h-4 w-4 text-blue-600 rounded border-gray-300 focus:ring-blue-500"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white truncate">
|
||||
{activity.name.de}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{activity.vvtId} · {activity.status}
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge status={activity.status} />
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedReportType === 'VENDOR_AUDIT' && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Vendor auswählen
|
||||
</h2>
|
||||
<button
|
||||
onClick={selectAllVendors}
|
||||
className="text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400"
|
||||
>
|
||||
Alle auswählen
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4 max-h-64 overflow-y-auto">
|
||||
{vendors.length === 0 ? (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 text-center py-4">
|
||||
Keine Vendors vorhanden
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{vendors.map((vendor) => (
|
||||
<label
|
||||
key={vendor.id}
|
||||
className="flex items-center gap-3 p-2 rounded hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedVendors.includes(vendor.id)}
|
||||
onChange={() => toggleVendor(vendor.id)}
|
||||
className="h-4 w-4 text-blue-600 rounded border-gray-300 focus:ring-blue-500"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white truncate">
|
||||
{vendor.name}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{vendor.country} · {vendor.serviceCategory}
|
||||
</p>
|
||||
</div>
|
||||
<RiskBadge score={vendor.inherentRiskScore} />
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Include Options */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Optionen
|
||||
</h2>
|
||||
</div>
|
||||
<div className="p-4 space-y-3">
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeFindings}
|
||||
onChange={(e) => setIncludeFindings(e.target.checked)}
|
||||
className="h-4 w-4 text-blue-600 rounded border-gray-300 focus:ring-blue-500"
|
||||
/>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
Findings einbeziehen
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Offene und behobene Vertragsprüfungs-Findings
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeControls}
|
||||
onChange={(e) => setIncludeControls(e.target.checked)}
|
||||
className="h-4 w-4 text-blue-600 rounded border-gray-300 focus:ring-blue-500"
|
||||
/>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
Control-Status einbeziehen
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Übersicht aller Kontrollen und deren Erfüllungsstatus
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeRiskAssessment}
|
||||
onChange={(e) => setIncludeRiskAssessment(e.target.checked)}
|
||||
className="h-4 w-4 text-blue-600 rounded border-gray-300 focus:ring-blue-500"
|
||||
/>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
Risikobewertung einbeziehen
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Inhärentes und Restrisiko mit Begründung
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Export Panel */}
|
||||
<div className="space-y-6">
|
||||
{/* Format & Export */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Export
|
||||
</h2>
|
||||
</div>
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Selected Report Info */}
|
||||
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-xl">{reportMeta.icon}</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{reportMeta.title}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{reportMeta.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Format Selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Format
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{reportMeta.formats.map((format) => (
|
||||
<button
|
||||
key={format}
|
||||
onClick={() => setSelectedFormat(format)}
|
||||
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
selectedFormat === format
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
{FORMAT_META[format].icon} {FORMAT_META[format].label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scope Summary */}
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
<p>
|
||||
{selectedReportType === 'VENDOR_AUDIT'
|
||||
? `${selectedVendors.length || 'Alle'} Vendor(s) ausgewählt`
|
||||
: selectedReportType === 'MANAGEMENT_SUMMARY'
|
||||
? 'Gesamtübersicht'
|
||||
: `${selectedActivities.length || 'Alle'} Verarbeitung(en) ausgewählt`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Export Button */}
|
||||
<button
|
||||
onClick={handleExport}
|
||||
disabled={isGenerating}
|
||||
className={`w-full py-3 px-4 rounded-lg font-medium text-white transition-colors ${
|
||||
isGenerating
|
||||
? 'bg-gray-400 cursor-not-allowed'
|
||||
: 'bg-blue-600 hover:bg-blue-700'
|
||||
}`}
|
||||
>
|
||||
{isGenerating ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<svg
|
||||
className="animate-spin h-5 w-5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
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>
|
||||
Wird generiert...
|
||||
</span>
|
||||
) : (
|
||||
`${reportMeta.title} exportieren`
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Reports */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Letzte Reports
|
||||
</h2>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
{generatedReports.length === 0 ? (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 text-center py-4">
|
||||
Noch keine Reports generiert
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{generatedReports.slice(0, 5).map((report) => (
|
||||
<div
|
||||
key={report.id}
|
||||
className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-lg">
|
||||
{REPORT_TYPE_META[report.type].icon}
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{report.filename}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{report.generatedAt.toLocaleString('de-DE')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button className="text-blue-600 hover:text-blue-800 dark:text-blue-400">
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Help / Templates */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Hilfe
|
||||
</h2>
|
||||
</div>
|
||||
<div className="p-4 space-y-3 text-sm">
|
||||
<div className="flex gap-2">
|
||||
<span>📋</span>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900 dark:text-white">
|
||||
VVT Export
|
||||
</p>
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
Art. 30 DSGVO konformes Verzeichnis aller
|
||||
Verarbeitungstätigkeiten
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<span>🔍</span>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900 dark:text-white">
|
||||
Vendor Audit
|
||||
</p>
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
Komplette Dokumentation für Due Diligence und Audits
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<span>📊</span>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900 dark:text-white">
|
||||
Management Summary
|
||||
</p>
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
Übersicht für Geschäftsführung und DSB
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Helper Components
|
||||
|
||||
function StatCard({
|
||||
label,
|
||||
value,
|
||||
subtext,
|
||||
color,
|
||||
}: {
|
||||
label: string
|
||||
value: number
|
||||
subtext: string
|
||||
color: 'blue' | 'purple' | 'green' | 'yellow' | 'red'
|
||||
}) {
|
||||
const colors = {
|
||||
blue: 'bg-blue-50 dark:bg-blue-900/20',
|
||||
purple: 'bg-purple-50 dark:bg-purple-900/20',
|
||||
green: 'bg-green-50 dark:bg-green-900/20',
|
||||
yellow: 'bg-yellow-50 dark:bg-yellow-900/20',
|
||||
red: 'bg-red-50 dark:bg-red-900/20',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${colors[color]} rounded-lg p-4`}>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">{label}</p>
|
||||
<p className="mt-1 text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{value}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">{subtext}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const statusStyles: Record<string, string> = {
|
||||
DRAFT: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300',
|
||||
REVIEW: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300',
|
||||
APPROVED: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300',
|
||||
ARCHIVED: 'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400',
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${
|
||||
statusStyles[status] || statusStyles.DRAFT
|
||||
}`}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function RiskBadge({ score }: { score: number }) {
|
||||
let colorClass = 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300'
|
||||
if (score >= 70) {
|
||||
colorClass = 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300'
|
||||
} else if (score >= 50) {
|
||||
colorClass = 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300'
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${colorClass}`}
|
||||
>
|
||||
{score}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,669 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect, useMemo } from 'react'
|
||||
import { useSDK } from '@/lib/sdk'
|
||||
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
||||
import {
|
||||
WhistleblowerReport,
|
||||
WhistleblowerStatistics,
|
||||
ReportCategory,
|
||||
ReportStatus,
|
||||
ReportPriority,
|
||||
REPORT_CATEGORY_INFO,
|
||||
REPORT_STATUS_INFO,
|
||||
isAcknowledgmentOverdue,
|
||||
isFeedbackOverdue,
|
||||
getDaysUntilAcknowledgment,
|
||||
getDaysUntilFeedback
|
||||
} from '@/lib/sdk/whistleblower/types'
|
||||
import { fetchSDKWhistleblowerList } from '@/lib/sdk/whistleblower/api'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
type TabId = 'overview' | 'new_reports' | 'investigation' | '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'
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
function FilterBar({
|
||||
selectedCategory,
|
||||
selectedStatus,
|
||||
selectedPriority,
|
||||
onCategoryChange,
|
||||
onStatusChange,
|
||||
onPriorityChange,
|
||||
onClear
|
||||
}: {
|
||||
selectedCategory: ReportCategory | 'all'
|
||||
selectedStatus: ReportStatus | 'all'
|
||||
selectedPriority: ReportPriority | 'all'
|
||||
onCategoryChange: (category: ReportCategory | 'all') => void
|
||||
onStatusChange: (status: ReportStatus | 'all') => void
|
||||
onPriorityChange: (priority: ReportPriority | 'all') => void
|
||||
onClear: () => void
|
||||
}) {
|
||||
const hasFilters = selectedCategory !== 'all' || selectedStatus !== 'all' || selectedPriority !== '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 ReportCategory | '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(REPORT_CATEGORY_INFO).map(([key, info]) => (
|
||||
<option key={key} value={key}>{info.label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Status Filter */}
|
||||
<select
|
||||
value={selectedStatus}
|
||||
onChange={(e) => onStatusChange(e.target.value as ReportStatus | '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(REPORT_STATUS_INFO).map(([status, info]) => (
|
||||
<option key={status} value={status}>{info.label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Priority Filter */}
|
||||
<select
|
||||
value={selectedPriority}
|
||||
onChange={(e) => onPriorityChange(e.target.value as ReportPriority | '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 Prioritaeten</option>
|
||||
<option value="critical">Kritisch</option>
|
||||
<option value="high">Hoch</option>
|
||||
<option value="normal">Normal</option>
|
||||
<option value="low">Niedrig</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>
|
||||
)
|
||||
}
|
||||
|
||||
function ReportCard({ report }: { report: WhistleblowerReport }) {
|
||||
const categoryInfo = REPORT_CATEGORY_INFO[report.category]
|
||||
const statusInfo = REPORT_STATUS_INFO[report.status]
|
||||
const isClosed = report.status === 'closed' || report.status === 'rejected'
|
||||
|
||||
const ackOverdue = isAcknowledgmentOverdue(report)
|
||||
const fbOverdue = isFeedbackOverdue(report)
|
||||
const daysAck = getDaysUntilAcknowledgment(report)
|
||||
const daysFb = getDaysUntilFeedback(report)
|
||||
|
||||
const completedMeasures = report.measures.filter(m => m.status === 'completed').length
|
||||
const totalMeasures = report.measures.length
|
||||
|
||||
const priorityLabels: Record<ReportPriority, string> = {
|
||||
low: 'Niedrig',
|
||||
normal: 'Normal',
|
||||
high: 'Hoch',
|
||||
critical: 'Kritisch'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`
|
||||
bg-white rounded-xl border-2 p-6 hover:shadow-md transition-all cursor-pointer
|
||||
${ackOverdue || fbOverdue ? 'border-red-300 hover:border-red-400' :
|
||||
report.priority === 'critical' ? 'border-orange-300 hover:border-orange-400' :
|
||||
isClosed ? 'border-green-200 hover:border-green-300' :
|
||||
'border-gray-200 hover:border-purple-300'
|
||||
}
|
||||
`}>
|
||||
<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">
|
||||
{report.referenceNumber}
|
||||
</span>
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${categoryInfo.bgColor} ${categoryInfo.color}`}>
|
||||
{categoryInfo.label}
|
||||
</span>
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${statusInfo.bgColor} ${statusInfo.color}`}>
|
||||
{statusInfo.label}
|
||||
</span>
|
||||
{report.isAnonymous && (
|
||||
<span className="px-2 py-1 text-xs bg-gray-100 text-gray-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="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
Anonym
|
||||
</span>
|
||||
)}
|
||||
{report.priority === 'critical' && (
|
||||
<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>
|
||||
Kritisch
|
||||
</span>
|
||||
)}
|
||||
{report.priority === 'high' && (
|
||||
<span className="px-2 py-1 text-xs bg-orange-100 text-orange-700 rounded-full">
|
||||
Hoch
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h3 className="text-lg font-semibold text-gray-900 truncate">
|
||||
{report.title}
|
||||
</h3>
|
||||
|
||||
{/* Description Preview */}
|
||||
{report.description && (
|
||||
<p className="text-sm text-gray-500 mt-1 line-clamp-2">
|
||||
{report.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Deadline Info */}
|
||||
{!isClosed && (
|
||||
<div className="flex items-center gap-4 mt-3 text-xs">
|
||||
{report.status === 'new' && (
|
||||
<span className={`flex items-center gap-1 ${ackOverdue ? 'text-red-600 font-medium' : daysAck <= 2 ? 'text-orange-600' : 'text-gray-500'}`}>
|
||||
<svg className="w-3.5 h-3.5" 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>
|
||||
{ackOverdue
|
||||
? `Bestaetigung ${Math.abs(daysAck)} Tage ueberfaellig`
|
||||
: `Bestaetigung in ${daysAck} Tagen`
|
||||
}
|
||||
</span>
|
||||
)}
|
||||
<span className={`flex items-center gap-1 ${fbOverdue ? 'text-red-600 font-medium' : daysFb <= 14 ? 'text-orange-600' : 'text-gray-500'}`}>
|
||||
<svg className="w-3.5 h-3.5" 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>
|
||||
{fbOverdue
|
||||
? `Rueckmeldung ${Math.abs(daysFb)} Tage ueberfaellig`
|
||||
: `Rueckmeldung in ${daysFb} Tagen`
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right Side - Date & Priority */}
|
||||
<div className={`text-right ml-4 ${
|
||||
ackOverdue || fbOverdue ? 'text-red-600' :
|
||||
report.priority === 'critical' ? 'text-orange-600' :
|
||||
'text-gray-500'
|
||||
}`}>
|
||||
<div className="text-sm font-medium">
|
||||
{isClosed
|
||||
? statusInfo.label
|
||||
: ackOverdue
|
||||
? 'Ueberfaellig'
|
||||
: priorityLabels[report.priority]
|
||||
}
|
||||
</div>
|
||||
<div className="text-xs mt-0.5">
|
||||
{new Date(report.receivedAt).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">
|
||||
<div className="text-sm text-gray-500">
|
||||
{report.assignedTo
|
||||
? `Zugewiesen: ${report.assignedTo}`
|
||||
: 'Nicht zugewiesen'
|
||||
}
|
||||
</div>
|
||||
{report.attachments.length > 0 && (
|
||||
<span className="flex items-center gap-1 text-xs text-gray-400">
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" />
|
||||
</svg>
|
||||
{report.attachments.length} Anhang{report.attachments.length !== 1 ? 'e' : ''}
|
||||
</span>
|
||||
)}
|
||||
{totalMeasures > 0 && (
|
||||
<span className={`flex items-center gap-1 text-xs ${completedMeasures === totalMeasures ? 'text-green-600' : 'text-yellow-600'}`}>
|
||||
<svg className="w-3.5 h-3.5" 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}/{totalMeasures} Massnahmen
|
||||
</span>
|
||||
)}
|
||||
{report.messages.length > 0 && (
|
||||
<span className="flex items-center gap-1 text-xs text-gray-400">
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
</svg>
|
||||
{report.messages.length} Nachricht{report.messages.length !== 1 ? 'en' : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{!isClosed && (
|
||||
<span className="px-3 py-1 text-sm text-purple-600 hover:bg-purple-50 rounded-lg transition-colors">
|
||||
Bearbeiten
|
||||
</span>
|
||||
)}
|
||||
{isClosed && (
|
||||
<span className="px-3 py-1 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
Details
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN PAGE
|
||||
// =============================================================================
|
||||
|
||||
export default function WhistleblowerPage() {
|
||||
const { state } = useSDK()
|
||||
const [activeTab, setActiveTab] = useState<TabId>('overview')
|
||||
const [reports, setReports] = useState<WhistleblowerReport[]>([])
|
||||
const [statistics, setStatistics] = useState<WhistleblowerStatistics | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
// Filters
|
||||
const [selectedCategory, setSelectedCategory] = useState<ReportCategory | 'all'>('all')
|
||||
const [selectedStatus, setSelectedStatus] = useState<ReportStatus | 'all'>('all')
|
||||
const [selectedPriority, setSelectedPriority] = useState<ReportPriority | 'all'>('all')
|
||||
|
||||
// Load data from SDK backend
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const { reports: wbReports, statistics: wbStats } = await fetchSDKWhistleblowerList()
|
||||
setReports(wbReports)
|
||||
setStatistics(wbStats)
|
||||
} catch (error) {
|
||||
console.error('Failed to load Whistleblower data:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
loadData()
|
||||
}, [])
|
||||
|
||||
// Locally computed overdue counts (always fresh)
|
||||
const overdueCounts = useMemo(() => {
|
||||
const overdueAck = reports.filter(r => isAcknowledgmentOverdue(r)).length
|
||||
const overdueFb = reports.filter(r => isFeedbackOverdue(r)).length
|
||||
return { overdueAck, overdueFb }
|
||||
}, [reports])
|
||||
|
||||
// Calculate tab counts
|
||||
const tabCounts = useMemo(() => {
|
||||
const investigationStatuses: ReportStatus[] = ['acknowledged', 'under_review', 'investigation', 'measures_taken']
|
||||
const closedStatuses: ReportStatus[] = ['closed', 'rejected']
|
||||
return {
|
||||
new_reports: reports.filter(r => r.status === 'new').length,
|
||||
investigation: reports.filter(r => investigationStatuses.includes(r.status)).length,
|
||||
closed: reports.filter(r => closedStatuses.includes(r.status)).length
|
||||
}
|
||||
}, [reports])
|
||||
|
||||
// Filter reports based on active tab and filters
|
||||
const filteredReports = useMemo(() => {
|
||||
let filtered = [...reports]
|
||||
|
||||
// Tab-based filtering
|
||||
const investigationStatuses: ReportStatus[] = ['acknowledged', 'under_review', 'investigation', 'measures_taken']
|
||||
const closedStatuses: ReportStatus[] = ['closed', 'rejected']
|
||||
|
||||
if (activeTab === 'new_reports') {
|
||||
filtered = filtered.filter(r => r.status === 'new')
|
||||
} else if (activeTab === 'investigation') {
|
||||
filtered = filtered.filter(r => investigationStatuses.includes(r.status))
|
||||
} else if (activeTab === 'closed') {
|
||||
filtered = filtered.filter(r => closedStatuses.includes(r.status))
|
||||
}
|
||||
|
||||
// Category filter
|
||||
if (selectedCategory !== 'all') {
|
||||
filtered = filtered.filter(r => r.category === selectedCategory)
|
||||
}
|
||||
|
||||
// Status filter
|
||||
if (selectedStatus !== 'all') {
|
||||
filtered = filtered.filter(r => r.status === selectedStatus)
|
||||
}
|
||||
|
||||
// Priority filter
|
||||
if (selectedPriority !== 'all') {
|
||||
filtered = filtered.filter(r => r.priority === selectedPriority)
|
||||
}
|
||||
|
||||
// Sort: overdue first, then by priority, then by date
|
||||
return filtered.sort((a, b) => {
|
||||
const closedStatuses: ReportStatus[] = ['closed', 'rejected']
|
||||
|
||||
const getUrgency = (r: WhistleblowerReport) => {
|
||||
if (closedStatuses.includes(r.status)) return 1000
|
||||
const ackOd = isAcknowledgmentOverdue(r)
|
||||
const fbOd = isFeedbackOverdue(r)
|
||||
if (ackOd || fbOd) return -100
|
||||
const priorityScore = { critical: 0, high: 1, normal: 2, low: 3 }
|
||||
return priorityScore[r.priority] ?? 2
|
||||
}
|
||||
|
||||
const urgencyDiff = getUrgency(a) - getUrgency(b)
|
||||
if (urgencyDiff !== 0) return urgencyDiff
|
||||
|
||||
return new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime()
|
||||
})
|
||||
}, [reports, activeTab, selectedCategory, selectedStatus, selectedPriority])
|
||||
|
||||
const tabs: Tab[] = [
|
||||
{ id: 'overview', label: 'Uebersicht' },
|
||||
{ id: 'new_reports', label: 'Neue Meldungen', count: tabCounts.new_reports, countColor: 'bg-blue-100 text-blue-600' },
|
||||
{ id: 'investigation', label: 'In Untersuchung', count: tabCounts.investigation, countColor: 'bg-yellow-100 text-yellow-600' },
|
||||
{ id: 'closed', label: 'Abgeschlossen', count: tabCounts.closed, countColor: 'bg-green-100 text-green-600' },
|
||||
{ id: 'settings', label: 'Einstellungen' }
|
||||
]
|
||||
|
||||
const stepInfo = STEP_EXPLANATIONS['whistleblower']
|
||||
|
||||
const clearFilters = () => {
|
||||
setSelectedCategory('all')
|
||||
setSelectedStatus('all')
|
||||
setSelectedPriority('all')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Step Header - NO "create report" button (reports come from the public form) */}
|
||||
<StepHeader
|
||||
stepId="whistleblower"
|
||||
title={stepInfo.title}
|
||||
description={stepInfo.description}
|
||||
explanation={stepInfo.explanation}
|
||||
tips={stepInfo.tips}
|
||||
/>
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<TabNavigation
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
/>
|
||||
|
||||
{/* Loading State */}
|
||||
{isLoading ? (
|
||||
<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>
|
||||
) : activeTab === 'settings' ? (
|
||||
/* Settings Tab */
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-8 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="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.065 2.572c1.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.572 1.065c-.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.065-2.572c-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>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Einstellungen</h3>
|
||||
<p className="mt-2 text-gray-500">
|
||||
Hinweisgebersystem-Einstellungen, Meldekanal-Konfiguration, Ombudsperson-Verwaltung
|
||||
und E-Mail-Vorlagen werden in einer spaeteren Version verfuegbar sein.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Statistics (Overview Tab) */}
|
||||
{activeTab === 'overview' && statistics && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
label="Gesamt Meldungen"
|
||||
value={statistics.totalReports}
|
||||
color="gray"
|
||||
/>
|
||||
<StatCard
|
||||
label="Neue Meldungen"
|
||||
value={statistics.newReports}
|
||||
color="blue"
|
||||
/>
|
||||
<StatCard
|
||||
label="In Untersuchung"
|
||||
value={statistics.underReview}
|
||||
color="yellow"
|
||||
/>
|
||||
<StatCard
|
||||
label="Ueberfaellige Bestaetigung"
|
||||
value={overdueCounts.overdueAck}
|
||||
color={overdueCounts.overdueAck > 0 ? 'red' : 'green'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Overdue Alert for Acknowledgment Deadline (7 days HinSchG) */}
|
||||
{(overdueCounts.overdueAck > 0 || overdueCounts.overdueFb > 0) && (activeTab === 'overview' || activeTab === 'new_reports' || activeTab === 'investigation') && (
|
||||
<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: Gesetzliche Fristen ueberschritten
|
||||
</h4>
|
||||
<p className="text-sm text-red-600 mt-0.5">
|
||||
{overdueCounts.overdueAck > 0 && (
|
||||
<span>{overdueCounts.overdueAck} Meldung(en) ohne Eingangsbestaetigung (mehr als 7 Tage, HinSchG ss 17 Abs. 1). </span>
|
||||
)}
|
||||
{overdueCounts.overdueFb > 0 && (
|
||||
<span>{overdueCounts.overdueFb} Meldung(en) ohne Rueckmeldung (mehr als 3 Monate, HinSchG ss 17 Abs. 2). </span>
|
||||
)}
|
||||
Handeln Sie umgehend, um Bussgelder und Haftungsrisiken zu vermeiden.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (overdueCounts.overdueAck > 0) {
|
||||
setActiveTab('new_reports')
|
||||
} else {
|
||||
setActiveTab('investigation')
|
||||
}
|
||||
}}
|
||||
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 about HinSchG Deadlines (Overview Tab) */}
|
||||
{activeTab === 'overview' && (
|
||||
<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">HinSchG-Fristen</h4>
|
||||
<p className="text-sm text-blue-600 mt-1">
|
||||
Nach dem Hinweisgeberschutzgesetz (HinSchG) gelten folgende Fristen:
|
||||
Die Eingangsbestaetigung muss innerhalb von <strong>7 Tagen</strong> an den
|
||||
Hinweisgeber versendet werden (ss 17 Abs. 1 S. 2).
|
||||
Eine Rueckmeldung ueber ergriffene Massnahmen muss innerhalb von <strong>3 Monaten</strong> nach
|
||||
Eingangsbestaetigung erfolgen (ss 17 Abs. 2).
|
||||
Der Schutz des Hinweisgebers vor Repressalien ist zwingend sicherzustellen (ss 36).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<FilterBar
|
||||
selectedCategory={selectedCategory}
|
||||
selectedStatus={selectedStatus}
|
||||
selectedPriority={selectedPriority}
|
||||
onCategoryChange={setSelectedCategory}
|
||||
onStatusChange={setSelectedStatus}
|
||||
onPriorityChange={setSelectedPriority}
|
||||
onClear={clearFilters}
|
||||
/>
|
||||
|
||||
{/* Report List */}
|
||||
<div className="space-y-4">
|
||||
{filteredReports.map(report => (
|
||||
<ReportCard key={report.id} report={report} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Empty State */}
|
||||
{filteredReports.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="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Keine Meldungen gefunden</h3>
|
||||
<p className="mt-2 text-gray-500">
|
||||
{selectedCategory !== 'all' || selectedStatus !== 'all' || selectedPriority !== 'all'
|
||||
? 'Passen Sie die Filter an oder setzen Sie sie zurueck.'
|
||||
: 'Es sind noch keine Meldungen im Hinweisgebersystem vorhanden. Meldungen werden ueber das oeffentliche Meldeformular eingereicht.'
|
||||
}
|
||||
</p>
|
||||
{(selectedCategory !== 'all' || selectedStatus !== 'all' || selectedPriority !== 'all') && (
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
className="mt-4 px-4 py-2 text-purple-600 hover:bg-purple-50 rounded-lg transition-colors"
|
||||
>
|
||||
Filter zuruecksetzen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
121
admin-compliance/app/api/admin/consent/[[...path]]/route.ts
Normal file
121
admin-compliance/app/api/admin/consent/[[...path]]/route.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Admin Consent API Proxy - Catch-all route
|
||||
* Proxies all /api/admin/consent/* requests to backend-compliance
|
||||
*
|
||||
* Maps: /api/admin/consent/<path> → backend-compliance:8002/api/compliance/legal-documents/<path>
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
async function proxyRequest(
|
||||
request: NextRequest,
|
||||
pathSegments: string[] | undefined,
|
||||
method: string
|
||||
) {
|
||||
const pathStr = pathSegments?.join('/') || ''
|
||||
const searchParams = request.nextUrl.searchParams.toString()
|
||||
const basePath = `${BACKEND_URL}/api/compliance/legal-documents`
|
||||
const url = pathStr
|
||||
? `${basePath}/${pathStr}${searchParams ? `?${searchParams}` : ''}`
|
||||
: `${basePath}${searchParams ? `?${searchParams}` : ''}`
|
||||
|
||||
try {
|
||||
const headers: HeadersInit = {}
|
||||
const contentType = request.headers.get('Content-Type')
|
||||
if (contentType) headers['Content-Type'] = contentType
|
||||
|
||||
const headerNames = ['authorization', 'x-namespace-id', 'x-tenant-slug']
|
||||
for (const name of headerNames) {
|
||||
const value = request.headers.get(name)
|
||||
if (value) {
|
||||
headers[name] = value
|
||||
}
|
||||
}
|
||||
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
const clientUserId = request.headers.get('x-user-id')
|
||||
const clientTenantId = request.headers.get('x-tenant-id')
|
||||
headers['X-User-ID'] = (clientUserId && uuidRegex.test(clientUserId)) ? clientUserId : '00000000-0000-0000-0000-000000000001'
|
||||
headers['X-Tenant-ID'] = (clientTenantId && uuidRegex.test(clientTenantId)) ? clientTenantId : (process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e')
|
||||
|
||||
const fetchOptions: RequestInit = {
|
||||
method,
|
||||
headers,
|
||||
signal: AbortSignal.timeout(60000),
|
||||
}
|
||||
|
||||
if (method === 'POST' || method === 'PUT') {
|
||||
const isMultipart = contentType?.includes('multipart/form-data')
|
||||
if (isMultipart) {
|
||||
const buffer = await request.arrayBuffer()
|
||||
if (buffer.byteLength > 0) {
|
||||
fetchOptions.body = buffer
|
||||
}
|
||||
} else {
|
||||
const body = await request.text()
|
||||
if (body) {
|
||||
fetchOptions.body = body
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(url, fetchOptions)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
let errorJson
|
||||
try {
|
||||
errorJson = JSON.parse(errorText)
|
||||
} catch {
|
||||
errorJson = { error: errorText }
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: `Backend Error: ${response.status}`, ...errorJson },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (error) {
|
||||
console.error('Admin Consent API proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Verbindung zum Compliance Backend fehlgeschlagen' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'GET')
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'POST')
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'PUT')
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'DELETE')
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
/**
|
||||
* Content API Route
|
||||
*
|
||||
* GET: Load current website content
|
||||
* POST: Save changed content (Admin only)
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getContent, saveContent } from '@/lib/content'
|
||||
import type { WebsiteContent } from '@/lib/content-types'
|
||||
|
||||
// GET - Load content
|
||||
export async function GET() {
|
||||
try {
|
||||
const content = getContent()
|
||||
return NextResponse.json(content)
|
||||
} catch (error) {
|
||||
console.error('Error loading content:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to load content' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// POST - Save content
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Simple admin check via header or query
|
||||
// In production: JWT/Session-based auth
|
||||
const adminKey = request.headers.get('x-admin-key')
|
||||
const expectedKey = process.env.ADMIN_API_KEY || 'breakpilot-admin-2024'
|
||||
|
||||
if (adminKey !== expectedKey) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const content: WebsiteContent = await request.json()
|
||||
|
||||
// Validation
|
||||
if (!content.hero || !content.features || !content.faq || !content.pricing) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid content structure' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const result = saveContent(content)
|
||||
|
||||
if (result.success) {
|
||||
return NextResponse.json({ success: true, message: 'Content saved' })
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{ error: result.error || 'Failed to save content' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving content:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to save content' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
/**
|
||||
* DSFA Corpus API Proxy
|
||||
*
|
||||
* Proxies requests to klausur-service for DSFA RAG operations.
|
||||
* Endpoints: /api/v1/dsfa-rag/stats, /api/v1/dsfa-rag/sources
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const KLAUSUR_SERVICE_URL = process.env.KLAUSUR_SERVICE_URL || 'http://klausur-service:8086'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const action = searchParams.get('action')
|
||||
|
||||
try {
|
||||
let url = `${KLAUSUR_SERVICE_URL}/api/v1/dsfa-rag`
|
||||
|
||||
switch (action) {
|
||||
case 'status':
|
||||
url += '/stats'
|
||||
break
|
||||
case 'sources':
|
||||
url += '/sources'
|
||||
break
|
||||
case 'source-detail': {
|
||||
const code = searchParams.get('code')
|
||||
if (!code) {
|
||||
return NextResponse.json({ error: 'Missing code parameter' }, { status: 400 })
|
||||
}
|
||||
url += `/sources/${encodeURIComponent(code)}`
|
||||
break
|
||||
}
|
||||
default:
|
||||
return NextResponse.json({ error: 'Unknown action' }, { status: 400 })
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
cache: 'no-store',
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
return NextResponse.json(data, { status: res.status })
|
||||
} catch (error) {
|
||||
console.error('DSFA corpus proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to klausur-service' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const action = searchParams.get('action')
|
||||
|
||||
try {
|
||||
let url = `${KLAUSUR_SERVICE_URL}/api/v1/dsfa-rag`
|
||||
|
||||
switch (action) {
|
||||
case 'init': {
|
||||
url += '/init'
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
const data = await res.json()
|
||||
return NextResponse.json(data, { status: res.status })
|
||||
}
|
||||
|
||||
case 'ingest': {
|
||||
const body = await request.json()
|
||||
const sourceCode = body.source_code
|
||||
if (!sourceCode) {
|
||||
return NextResponse.json({ error: 'Missing source_code' }, { status: 400 })
|
||||
}
|
||||
url += `/sources/${encodeURIComponent(sourceCode)}/ingest`
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
const data = await res.json()
|
||||
return NextResponse.json(data, { status: res.status })
|
||||
}
|
||||
|
||||
default:
|
||||
return NextResponse.json({ error: 'Unknown action' }, { status: 400 })
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('DSFA corpus proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to klausur-service' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
/**
|
||||
* Legal Corpus API Proxy
|
||||
*
|
||||
* Proxies requests to klausur-service for RAG operations.
|
||||
* This allows the client-side RAG page to call the API without CORS issues.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const KLAUSUR_SERVICE_URL = process.env.KLAUSUR_SERVICE_URL || 'http://klausur-service:8086'
|
||||
const QDRANT_URL = process.env.QDRANT_URL || 'http://qdrant:6333'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const action = searchParams.get('action')
|
||||
|
||||
try {
|
||||
let url = `${KLAUSUR_SERVICE_URL}/api/v1/admin/legal-corpus`
|
||||
|
||||
switch (action) {
|
||||
case 'status': {
|
||||
// Query Qdrant directly for collection stats
|
||||
const qdrantRes = await fetch(`${QDRANT_URL}/collections/bp_legal_corpus`, {
|
||||
cache: 'no-store',
|
||||
})
|
||||
if (!qdrantRes.ok) {
|
||||
return NextResponse.json({ error: 'Qdrant not available' }, { status: 503 })
|
||||
}
|
||||
const qdrantData = await qdrantRes.json()
|
||||
const result = qdrantData.result || {}
|
||||
return NextResponse.json({
|
||||
collection: 'bp_legal_corpus',
|
||||
totalPoints: result.points_count || 0,
|
||||
vectorSize: result.config?.params?.vectors?.size || 0,
|
||||
status: result.status || 'unknown',
|
||||
regulations: {},
|
||||
})
|
||||
}
|
||||
case 'search':
|
||||
const query = searchParams.get('query')
|
||||
const topK = searchParams.get('top_k') || '5'
|
||||
const regulations = searchParams.get('regulations')
|
||||
url += `/search?query=${encodeURIComponent(query || '')}&top_k=${topK}`
|
||||
if (regulations) {
|
||||
url += `®ulations=${encodeURIComponent(regulations)}`
|
||||
}
|
||||
break
|
||||
case 'ingestion-status':
|
||||
url += '/ingestion-status'
|
||||
break
|
||||
case 'regulations':
|
||||
url += '/regulations'
|
||||
break
|
||||
case 'custom-documents':
|
||||
url += '/custom-documents'
|
||||
break
|
||||
case 'pipeline-checkpoints':
|
||||
url = `${KLAUSUR_SERVICE_URL}/api/v1/admin/pipeline/checkpoints`
|
||||
break
|
||||
case 'pipeline-status':
|
||||
url = `${KLAUSUR_SERVICE_URL}/api/v1/admin/pipeline/status`
|
||||
break
|
||||
case 'traceability': {
|
||||
const chunkId = searchParams.get('chunk_id')
|
||||
const regulation = searchParams.get('regulation')
|
||||
url += `/traceability?chunk_id=${encodeURIComponent(chunkId || '')}®ulation=${encodeURIComponent(regulation || '')}`
|
||||
break
|
||||
}
|
||||
default:
|
||||
return NextResponse.json({ error: 'Unknown action' }, { status: 400 })
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
cache: 'no-store',
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
return NextResponse.json(data, { status: res.status })
|
||||
} catch (error) {
|
||||
console.error('Legal corpus proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to klausur-service' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const action = searchParams.get('action')
|
||||
|
||||
try {
|
||||
let url = `${KLAUSUR_SERVICE_URL}/api/v1/admin/legal-corpus`
|
||||
|
||||
switch (action) {
|
||||
case 'ingest': {
|
||||
url += '/ingest'
|
||||
const body = await request.json()
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
const data = await res.json()
|
||||
return NextResponse.json(data, { status: res.status })
|
||||
}
|
||||
|
||||
case 'add-link': {
|
||||
url += '/add-link'
|
||||
const body = await request.json()
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
const data = await res.json()
|
||||
return NextResponse.json(data, { status: res.status })
|
||||
}
|
||||
|
||||
case 'upload': {
|
||||
url += '/upload'
|
||||
// Forward FormData directly
|
||||
const formData = await request.formData()
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
const data = await res.json()
|
||||
return NextResponse.json(data, { status: res.status })
|
||||
}
|
||||
|
||||
case 'start-pipeline': {
|
||||
url = `${KLAUSUR_SERVICE_URL}/api/v1/admin/pipeline/start`
|
||||
const body = await request.json()
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
const data = await res.json()
|
||||
return NextResponse.json(data, { status: res.status })
|
||||
}
|
||||
|
||||
default:
|
||||
return NextResponse.json({ error: 'Unknown action' }, { status: 400 })
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Legal corpus proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to klausur-service' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const action = searchParams.get('action')
|
||||
const docId = searchParams.get('docId')
|
||||
|
||||
try {
|
||||
if (action === 'delete-document' && docId) {
|
||||
const url = `${KLAUSUR_SERVICE_URL}/api/v1/admin/legal-corpus/custom-documents/${docId}`
|
||||
const res = await fetch(url, { method: 'DELETE' })
|
||||
const data = await res.json()
|
||||
return NextResponse.json(data, { status: res.status })
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'Unknown action' }, { status: 400 })
|
||||
} catch (error) {
|
||||
console.error('Legal corpus proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to klausur-service' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
37
admin-compliance/app/api/sdk/agents/[agentId]/route.ts
Normal file
37
admin-compliance/app/api/sdk/agents/[agentId]/route.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* GET /api/sdk/agents/[agentId] — Agent-Detail mit SOUL-Content
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getAgentById } from '@/lib/sdk/agents/agent-registry'
|
||||
import { readSoulFile, getSoulFileStats, soulFileExists } from '@/lib/sdk/agents/soul-reader'
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ agentId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { agentId } = await params
|
||||
const agent = getAgentById(agentId)
|
||||
|
||||
if (!agent) {
|
||||
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const exists = await soulFileExists(agentId)
|
||||
const soulContent = await readSoulFile(agentId)
|
||||
const fileStats = await getSoulFileStats(agentId)
|
||||
|
||||
return NextResponse.json({
|
||||
...agent,
|
||||
status: exists ? agent.status : 'error',
|
||||
soulContent: soulContent || '',
|
||||
createdAt: fileStats?.createdAt || null,
|
||||
updatedAt: fileStats?.updatedAt || null,
|
||||
fileSize: fileStats?.size || 0,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error fetching agent detail:', error)
|
||||
return NextResponse.json({ error: 'Failed to fetch agent' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
84
admin-compliance/app/api/sdk/agents/[agentId]/soul/route.ts
Normal file
84
admin-compliance/app/api/sdk/agents/[agentId]/soul/route.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* GET/PUT /api/sdk/agents/[agentId]/soul — SOUL-Datei lesen/schreiben
|
||||
*
|
||||
* GET: Content + Metadaten, ?history=true fuer Backup-Versionen
|
||||
* PUT: Backup erstellen -> neuen Content schreiben -> Cache invalidieren
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getAgentById } from '@/lib/sdk/agents/agent-registry'
|
||||
import {
|
||||
readSoulFile,
|
||||
writeSoulFile,
|
||||
listSoulBackups,
|
||||
getSoulFileStats,
|
||||
} from '@/lib/sdk/agents/soul-reader'
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ agentId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { agentId } = await params
|
||||
const agent = getAgentById(agentId)
|
||||
|
||||
if (!agent) {
|
||||
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const showHistory = request.nextUrl.searchParams.get('history') === 'true'
|
||||
|
||||
if (showHistory) {
|
||||
const backups = await listSoulBackups(agentId)
|
||||
return NextResponse.json({ agentId, backups })
|
||||
}
|
||||
|
||||
const content = await readSoulFile(agentId)
|
||||
const fileStats = await getSoulFileStats(agentId)
|
||||
|
||||
return NextResponse.json({
|
||||
agentId,
|
||||
content: content || '',
|
||||
updatedAt: fileStats?.updatedAt || null,
|
||||
size: fileStats?.size || 0,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error reading SOUL file:', error)
|
||||
return NextResponse.json({ error: 'Failed to read SOUL file' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ agentId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { agentId } = await params
|
||||
const agent = getAgentById(agentId)
|
||||
|
||||
if (!agent) {
|
||||
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { content } = body
|
||||
|
||||
if (typeof content !== 'string' || content.trim().length === 0) {
|
||||
return NextResponse.json({ error: 'Content is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
await writeSoulFile(agentId, content)
|
||||
|
||||
const fileStats = await getSoulFileStats(agentId)
|
||||
|
||||
return NextResponse.json({
|
||||
agentId,
|
||||
updatedAt: fileStats?.updatedAt || new Date().toISOString(),
|
||||
size: fileStats?.size || content.length,
|
||||
message: 'SOUL file updated successfully',
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error writing SOUL file:', error)
|
||||
return NextResponse.json({ error: 'Failed to write SOUL file' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
41
admin-compliance/app/api/sdk/agents/route.ts
Normal file
41
admin-compliance/app/api/sdk/agents/route.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* GET /api/sdk/agents — Liste aller Compliance-Agenten
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server'
|
||||
import { COMPLIANCE_AGENTS } from '@/lib/sdk/agents/agent-registry'
|
||||
import { soulFileExists } from '@/lib/sdk/agents/soul-reader'
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
// Check SOUL file existence for each agent and set status
|
||||
const agents = await Promise.all(
|
||||
COMPLIANCE_AGENTS.map(async (agent) => {
|
||||
const exists = await soulFileExists(agent.id)
|
||||
return {
|
||||
...agent,
|
||||
status: exists ? agent.status : 'error' as const,
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const activeCount = agents.filter(a => a.status === 'active').length
|
||||
const errorCount = agents.filter(a => a.status === 'error').length
|
||||
|
||||
return NextResponse.json({
|
||||
agents,
|
||||
stats: {
|
||||
total: agents.length,
|
||||
active: activeCount,
|
||||
inactive: agents.length - activeCount - errorCount,
|
||||
error: errorCount,
|
||||
totalSessions: 0,
|
||||
avgResponseTime: '—',
|
||||
},
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error fetching agents:', error)
|
||||
return NextResponse.json({ error: 'Failed to fetch agents' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
13
admin-compliance/app/api/sdk/agents/sessions/route.ts
Normal file
13
admin-compliance/app/api/sdk/agents/sessions/route.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* GET /api/sdk/agents/sessions — Agent-Sessions (Placeholder)
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({
|
||||
sessions: [],
|
||||
total: 0,
|
||||
message: 'Sessions-Tracking wird in einer zukuenftigen Version implementiert.',
|
||||
})
|
||||
}
|
||||
18
admin-compliance/app/api/sdk/agents/statistics/route.ts
Normal file
18
admin-compliance/app/api/sdk/agents/statistics/route.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* GET /api/sdk/agents/statistics — Agent-Statistiken (Placeholder)
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({
|
||||
statistics: {
|
||||
totalSessions: 0,
|
||||
totalMessages: 0,
|
||||
avgResponseTime: '—',
|
||||
successRate: '—',
|
||||
topTopics: [],
|
||||
},
|
||||
message: 'Detaillierte Statistiken werden in einer zukuenftigen Version implementiert.',
|
||||
})
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { readSoulFile } from '@/lib/sdk/agents/soul-reader'
|
||||
|
||||
const RAG_SERVICE_URL = process.env.RAG_SERVICE_URL || 'http://rag-service:8097'
|
||||
const OLLAMA_URL = process.env.OLLAMA_URL || 'http://host.docker.internal:11434'
|
||||
@@ -27,99 +28,18 @@ const COMPLIANCE_COLLECTIONS = [
|
||||
|
||||
type Country = 'DE' | 'AT' | 'CH' | 'EU'
|
||||
|
||||
// SOUL system prompt (from agent-core/soul/compliance-advisor.soul.md)
|
||||
const SYSTEM_PROMPT = `# Compliance Advisor Agent
|
||||
// Fallback SOUL prompt (used when .soul.md file is unavailable)
|
||||
const FALLBACK_SYSTEM_PROMPT = `# Compliance Advisor Agent
|
||||
|
||||
## Identitaet
|
||||
Du bist der BreakPilot Compliance-Berater. Du hilfst Nutzern des AI Compliance SDK,
|
||||
Datenschutz- und Compliance-Fragen in verstaendlicher Sprache zu beantworten.
|
||||
Du bist kein Anwalt und gibst keine Rechtsberatung, sondern orientierst dich an
|
||||
offiziellen Quellen und gibst praxisnahe Hinweise.
|
||||
|
||||
## Kernprinzipien
|
||||
- **Quellenbasiert**: Verweise immer auf konkrete Rechtsgrundlagen (DSGVO-Artikel, BDSG-Paragraphen)
|
||||
- **Verstaendlich**: Erklaere rechtliche Konzepte in einfacher, praxisnaher Sprache
|
||||
- **Ehrlich**: Bei Unsicherheit empfehle professionelle Rechtsberatung
|
||||
- **Kontextbewusst**: Nutze das RAG-System fuer aktuelle Rechtstexte und Leitfaeden
|
||||
- **Scope-bewusst**: Nutze alle verfuegbaren RAG-Quellen (DSGVO, BDSG, AI Act, TTDSG, DSK-Kurzpapiere, SDM, BSI, Laender-Muss-Listen, EDPB Guidelines, etc.) AUSSER NIBIS-Dokumenten.
|
||||
|
||||
## Kompetenzbereich
|
||||
- DSGVO Art. 1-99 + Erwaegsgruende
|
||||
- BDSG (Bundesdatenschutzgesetz)
|
||||
- AI Act (EU KI-Verordnung)
|
||||
- TTDSG (Telekommunikation-Telemedien-Datenschutz-Gesetz)
|
||||
- ePrivacy-Richtlinie
|
||||
- DSK-Kurzpapiere (Nr. 1-20)
|
||||
- SDM (Standard-Datenschutzmodell) V3.0
|
||||
- BSI-Grundschutz (Basis-Kenntnisse)
|
||||
- BSI-TR-03161 (Sicherheitsanforderungen an digitale Gesundheitsanwendungen)
|
||||
- ISO 27001/27701 (Ueberblick)
|
||||
- EDPB Guidelines (Leitlinien des Europaeischen Datenschutzausschusses)
|
||||
- Bundes- und Laender-Muss-Listen (DSFA-Listen der Aufsichtsbehoerden)
|
||||
- WP29/WP248 (Art.-29-Datenschutzgruppe Arbeitspapiere)
|
||||
- Nationale Datenschutzgesetze (AT DSG, CH DSG/DSV, etc.)
|
||||
- EU-Verordnungen (DORA, MiCA, Data Act, EHDS, PSD2, AMLR, etc.)
|
||||
- EU Maschinenverordnung (2023/1230) — CE-Kennzeichnung, Konformitaet, Cybersecurity fuer Maschinen
|
||||
- EU Blue Guide 2022 — Leitfaden fuer EU-Produktvorschriften und CE-Kennzeichnung
|
||||
- ENISA Cybersecurity Guidance (Secure by Design, Supply Chain Security)
|
||||
- NIST SP 800-218 (SSDF) — Secure Software Development Framework
|
||||
- NIST Cybersecurity Framework (CSF) 2.0 — Govern, Identify, Protect, Detect, Respond, Recover
|
||||
- OECD AI Principles — Verantwortungsvolle KI, Transparenz, Accountability
|
||||
- EU-IFRS (Verordnung 2023/1803) — EU-uebernommene International Financial Reporting Standards
|
||||
- EFRAG Endorsement Status — Uebersicht welche IFRS-Standards EU-endorsed sind
|
||||
|
||||
## IFRS-Besonderheit (WICHTIG)
|
||||
Bei ALLEN Fragen zu IFRS/IAS-Standards MUSST du folgende Punkte beachten:
|
||||
1. Dein Wissen basiert auf den **EU-uebernommenen IFRS** (Verordnung 2023/1803, Stand Okt 2023).
|
||||
2. Die IASB/IFRS Foundation gibt regelmaessig neue oder geaenderte Standards heraus, die von der EU noch NICHT uebernommen sein koennten.
|
||||
3. Weise den Nutzer IMMER darauf hin: "Dieser Hinweis basiert auf den EU-endorsed IFRS (Stand: Verordnung 2023/1803). Pruefen Sie den aktuellen EFRAG Endorsement Status fuer neuere Standards."
|
||||
4. Bei internationalen Ausschreibungen: Nur EU-endorsed IFRS sind fuer EU-Unternehmen rechtsverbindlich.
|
||||
5. Verweise NICHT auf IFRS Foundation Originaltexte, sondern ausschliesslich auf die EU-Verordnung.
|
||||
|
||||
## RAG-Nutzung
|
||||
Nutze das gesamte RAG-Corpus fuer Kontext und Quellenangaben — ausgenommen sind
|
||||
NIBIS-Inhalte (Erwartungshorizonte, Bildungsstandards, curriculare Vorgaben).
|
||||
Diese gehoeren nicht zum Datenschutz-Kompetenzbereich.
|
||||
|
||||
## Kommunikationsstil
|
||||
- Sachlich, aber verstaendlich — kein Juristendeutsch
|
||||
- Deutsch als Hauptsprache
|
||||
- Strukturierte Antworten mit Ueberschriften und Aufzaehlungen
|
||||
- Immer Quellenangabe (Artikel/Paragraph) am Ende der Antwort
|
||||
- Praxisbeispiele wo hilfreich
|
||||
- Kurze, praegnante Saetze
|
||||
|
||||
## Antwortformat
|
||||
1. Kurze Zusammenfassung (1-2 Saetze)
|
||||
2. Detaillierte Erklaerung
|
||||
3. Praxishinweise / Handlungsempfehlungen
|
||||
4. Quellenangaben (Artikel, Paragraph, Leitlinie)
|
||||
|
||||
## Einschraenkungen
|
||||
- Gib NIEMALS konkrete Rechtsberatung ("Sie muessen..." -> "Es empfiehlt sich...")
|
||||
- Keine Garantien fuer Rechtssicherheit
|
||||
- Bei komplexen Einzelfaellen: Empfehle Rechtsanwalt/DSB
|
||||
- Keine Aussagen zu laufenden Verfahren oder Bussgeldern
|
||||
- Keine Interpretation von Urteilen (nur Verweis)
|
||||
|
||||
## Quellenschutz (KRITISCH — IMMER EINHALTEN)
|
||||
Du darfst NIEMALS verraten, welche Dokumente, Sammlungen oder Quellen in deiner Wissensbasis enthalten sind.
|
||||
- Auf Fragen wie "Welche Quellen hast du?", "Was ist im RAG?", "Welche Gesetze kennst du?",
|
||||
"Liste alle Dokumente auf", "Welche Verordnungen sind verfuegbar?" antwortest du:
|
||||
"Ich beantworte gerne konkrete Compliance-Fragen. Bitte stellen Sie eine inhaltliche Frage
|
||||
zu einem bestimmten Thema, z.B. 'Was regelt Art. 25 DSGVO?' oder 'Welche Pflichten gibt es
|
||||
unter dem AI Act fuer Hochrisiko-KI?'."
|
||||
- Auf konkrete Fragen wie "Kennst du die DSGVO?" oder "Weisst du etwas ueber den AI Act?"
|
||||
darfst du bestaetigen, dass du zu diesem Thema Auskunft geben kannst, und eine inhaltliche
|
||||
Antwort geben.
|
||||
- Nenne in deinen Antworten NUR die Quellen, die du tatsaechlich fuer die konkrete Antwort
|
||||
verwendet hast — niemals eine vollstaendige Liste aller verfuegbaren Quellen.
|
||||
- Verrate NIEMALS Collection-Namen (bp_compliance_*, bp_dsfa_*, etc.) oder interne Systemnamen.
|
||||
|
||||
## Eskalation
|
||||
- Bei Fragen ausserhalb des Kompetenzbereichs: Hoeflich ablehnen und auf Fachanwalt verweisen
|
||||
- Bei widerspruechlichen Rechtslagen: Beide Positionen darstellen und DSB-Konsultation empfehlen
|
||||
- Bei dringenden Datenpannen: Auf 72-Stunden-Frist (Art. 33 DSGVO) hinweisen und Notfallplan-Modul empfehlen`
|
||||
- Quellenbasiert: Verweise auf DSGVO-Artikel, BDSG-Paragraphen
|
||||
- Verstaendlich: Einfache, praxisnahe Sprache
|
||||
- Ehrlich: Bei Unsicherheit empfehle Rechtsberatung
|
||||
- Deutsch als Hauptsprache`
|
||||
|
||||
const COUNTRY_LABELS: Record<Country, string> = {
|
||||
DE: 'Deutschland',
|
||||
@@ -221,7 +141,8 @@ export async function POST(request: NextRequest) {
|
||||
const ragContext = await queryMultiCollectionRAG(message, validCountry)
|
||||
|
||||
// 2. Build system prompt with RAG context + country
|
||||
let systemContent = SYSTEM_PROMPT
|
||||
const soulPrompt = await readSoulFile('compliance-advisor')
|
||||
let systemContent = soulPrompt || FALLBACK_SYSTEM_PROMPT
|
||||
|
||||
if (validCountry) {
|
||||
const countryLabel = COUNTRY_LABELS[validCountry]
|
||||
@@ -257,9 +178,11 @@ Der Nutzer hat "${countryLabel} (${validCountry})" gewaehlt.
|
||||
model: LLM_MODEL,
|
||||
messages,
|
||||
stream: true,
|
||||
think: false,
|
||||
options: {
|
||||
temperature: 0.3,
|
||||
num_predict: 8192,
|
||||
num_ctx: 8192,
|
||||
},
|
||||
}),
|
||||
signal: AbortSignal.timeout(120000),
|
||||
|
||||
@@ -7,56 +7,25 @@
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { queryRAG } from '@/lib/sdk/drafting-engine/rag-query'
|
||||
import { DOCUMENT_RAG_CONFIG } from '@/lib/sdk/drafting-engine/rag-config'
|
||||
import { readSoulFile } from '@/lib/sdk/agents/soul-reader'
|
||||
import type { ScopeDocumentType } from '@/lib/sdk/compliance-scope-types'
|
||||
|
||||
const KLAUSUR_SERVICE_URL = process.env.KLAUSUR_SERVICE_URL || 'http://klausur-service:8086'
|
||||
const OLLAMA_URL = process.env.OLLAMA_URL || 'http://host.docker.internal:11434'
|
||||
const LLM_MODEL = process.env.COMPLIANCE_LLM_MODEL || 'qwen2.5vl:32b'
|
||||
|
||||
// SOUL System Prompt (from agent-core/soul/drafting-agent.soul.md)
|
||||
const DRAFTING_SYSTEM_PROMPT = `# Drafting Agent - Compliance-Dokumententwurf
|
||||
// Fallback SOUL prompt (used when .soul.md file is unavailable)
|
||||
const FALLBACK_DRAFTING_PROMPT = `# Drafting Agent - Compliance-Dokumententwurf
|
||||
|
||||
## Identitaet
|
||||
Du bist der BreakPilot Drafting Agent. Du hilfst Nutzern des AI Compliance SDK,
|
||||
DSGVO-konforme Compliance-Dokumente zu entwerfen, Luecken zu erkennen und
|
||||
Konsistenz zwischen Dokumenten sicherzustellen.
|
||||
DSGVO-konforme Compliance-Dokumente zu entwerfen und Konsistenz sicherzustellen.
|
||||
|
||||
## Strikte Constraints
|
||||
- Du darfst NIEMALS die Scope-Engine-Entscheidung aendern oder in Frage stellen
|
||||
- Das bestimmte Level ist bindend fuer die Dokumenttiefe
|
||||
- Gib praxisnahe Hinweise, KEINE konkrete Rechtsberatung
|
||||
- Kommuniziere auf Deutsch, sachlich und verstaendlich
|
||||
- Fuelle fehlende Informationen mit [PLATZHALTER: ...] Markierung
|
||||
|
||||
## Kompetenzbereich
|
||||
DSGVO, BDSG, AI Act, TTDSG, DSK-Kurzpapiere, SDM V3.0, BSI-Grundschutz, ISO 27001/27701, EDPB Guidelines, WP248`
|
||||
|
||||
/**
|
||||
* Query the RAG corpus for relevant documents
|
||||
*/
|
||||
async function queryRAG(query: string): Promise<string> {
|
||||
try {
|
||||
const url = `${KLAUSUR_SERVICE_URL}/api/v1/dsfa-rag/search?query=${encodeURIComponent(query)}&top_k=3`
|
||||
const res = await fetch(url, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
})
|
||||
|
||||
if (!res.ok) return ''
|
||||
|
||||
const data = await res.json()
|
||||
if (data.results?.length > 0) {
|
||||
return data.results
|
||||
.map(
|
||||
(r: { source_name?: string; source_code?: string; content?: string }, i: number) =>
|
||||
`[Quelle ${i + 1}: ${r.source_name || r.source_code || 'Unbekannt'}]\n${r.content || ''}`
|
||||
)
|
||||
.join('\n\n---\n\n')
|
||||
}
|
||||
return ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
- Fuelle fehlende Informationen mit [PLATZHALTER: ...] Markierung`
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
@@ -73,11 +42,14 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Message is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// 1. Query RAG for legal context
|
||||
const ragContext = await queryRAG(message)
|
||||
// 1. Query RAG for legal context (use type-specific collection + query boost if available)
|
||||
const ragConfig = documentType ? DOCUMENT_RAG_CONFIG[documentType as ScopeDocumentType] : undefined
|
||||
const ragQuery = ragConfig ? `${ragConfig.query} ${message}` : message
|
||||
const ragContext = await queryRAG(ragQuery, 3, ragConfig?.collection)
|
||||
|
||||
// 2. Build system prompt with mode-specific instructions + state projection
|
||||
let systemContent = DRAFTING_SYSTEM_PROMPT
|
||||
const soulPrompt = await readSoulFile('drafting-agent')
|
||||
let systemContent = soulPrompt || FALLBACK_DRAFTING_PROMPT
|
||||
|
||||
// Mode-specific instructions
|
||||
const modeInstructions: Record<string, string> = {
|
||||
@@ -116,9 +88,11 @@ export async function POST(request: NextRequest) {
|
||||
model: LLM_MODEL,
|
||||
messages,
|
||||
stream: true,
|
||||
think: false,
|
||||
options: {
|
||||
temperature: mode === 'draft' ? 0.2 : 0.3,
|
||||
num_predict: mode === 'draft' ? 16384 : 8192,
|
||||
num_ctx: 8192,
|
||||
},
|
||||
}),
|
||||
signal: AbortSignal.timeout(120000),
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
/**
|
||||
* Drafting Engine - v2 Pipeline Helpers
|
||||
*
|
||||
* DOCUMENT_PROSE_BLOCKS, buildV2SystemPrompt, buildBlockSpecificPrompt,
|
||||
* callOllama, handleV2Draft — split from draft-helpers.ts for the 500 LOC hard cap.
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { DraftContext, DraftRevision, DraftSection } from '@/lib/sdk/drafting-engine/types'
|
||||
import type { ScopeDocumentType } from '@/lib/sdk/compliance-scope-types'
|
||||
import { deriveNarrativeTags, extractScoresFromDraftContext, narrativeTagsToPromptString } from '@/lib/sdk/drafting-engine/narrative-tags'
|
||||
import type { NarrativeTags } from '@/lib/sdk/drafting-engine/narrative-tags'
|
||||
import { buildAllowedFactsFromDraftContext, allowedFactsToPromptString, disallowedTopicsToPromptString } from '@/lib/sdk/drafting-engine/allowed-facts-v2'
|
||||
import { sanitizeAllowedFacts, validateNoRemainingPII, SanitizationError } from '@/lib/sdk/drafting-engine/sanitizer'
|
||||
import { terminologyToPromptString, styleContractToPromptString } from '@/lib/sdk/drafting-engine/terminology'
|
||||
import { executeRepairLoop, type ProseBlockOutput, type RepairAudit } from '@/lib/sdk/drafting-engine/prose-validator'
|
||||
import { computeChecksumSync, type CacheKeyParams } from '@/lib/sdk/drafting-engine/cache'
|
||||
import { queryRAG } from '@/lib/sdk/drafting-engine/rag-query'
|
||||
import { DOCUMENT_RAG_CONFIG } from '@/lib/sdk/drafting-engine/rag-config'
|
||||
import {
|
||||
constraintEnforcer,
|
||||
proseCache,
|
||||
TEMPLATE_VERSION,
|
||||
TERMINOLOGY_VERSION,
|
||||
VALIDATOR_VERSION,
|
||||
V1_SYSTEM_PROMPT,
|
||||
buildPromptForDocumentType,
|
||||
} from './draft-helpers'
|
||||
|
||||
const OLLAMA_URL = process.env.OLLAMA_URL || 'http://host.docker.internal:11434'
|
||||
const LLM_MODEL = process.env.COMPLIANCE_LLM_MODEL || 'qwen2.5vl:32b'
|
||||
|
||||
// ============================================================================
|
||||
// v2 Personalisierte Pipeline
|
||||
// ============================================================================
|
||||
|
||||
export const DOCUMENT_PROSE_BLOCKS: Record<string, Array<{ blockId: string; blockType: ProseBlockOutput['blockType']; sectionName: string; targetWords: number }>> = {
|
||||
tom: [
|
||||
{ blockId: 'tom-intro', blockType: 'introduction', sectionName: 'Einleitung TOM', targetWords: 120 },
|
||||
{ blockId: 'tom-transition', blockType: 'transition', sectionName: 'Ueberleitung Massnahmen', targetWords: 40 },
|
||||
{ blockId: 'tom-conclusion', blockType: 'conclusion', sectionName: 'Fazit TOM', targetWords: 80 },
|
||||
],
|
||||
dsfa: [
|
||||
{ blockId: 'dsfa-intro', blockType: 'introduction', sectionName: 'Einleitung DSFA', targetWords: 150 },
|
||||
{ blockId: 'dsfa-transition', blockType: 'transition', sectionName: 'Ueberleitung Risikobewertung', targetWords: 40 },
|
||||
{ blockId: 'dsfa-appreciation', blockType: 'appreciation', sectionName: 'Wuerdigung bestehender Massnahmen', targetWords: 60 },
|
||||
{ blockId: 'dsfa-conclusion', blockType: 'conclusion', sectionName: 'Fazit DSFA', targetWords: 100 },
|
||||
],
|
||||
vvt: [
|
||||
{ blockId: 'vvt-intro', blockType: 'introduction', sectionName: 'Einleitung VVT', targetWords: 120 },
|
||||
{ blockId: 'vvt-conclusion', blockType: 'conclusion', sectionName: 'Fazit VVT', targetWords: 80 },
|
||||
],
|
||||
dsi: [
|
||||
{ blockId: 'dsi-intro', blockType: 'introduction', sectionName: 'Einleitung Datenschutzerklaerung', targetWords: 130 },
|
||||
{ blockId: 'dsi-conclusion', blockType: 'conclusion', sectionName: 'Fazit Datenschutzerklaerung', targetWords: 80 },
|
||||
],
|
||||
lf: [
|
||||
{ blockId: 'lf-intro', blockType: 'introduction', sectionName: 'Einleitung Loeschfristen', targetWords: 100 },
|
||||
{ blockId: 'lf-conclusion', blockType: 'conclusion', sectionName: 'Fazit Loeschfristen', targetWords: 60 },
|
||||
],
|
||||
av_vertrag: [
|
||||
{ blockId: 'av-intro', blockType: 'introduction', sectionName: 'Einleitung Auftragsverarbeitung', targetWords: 130 },
|
||||
{ blockId: 'av-conclusion', blockType: 'conclusion', sectionName: 'Fazit Auftragsverarbeitung', targetWords: 80 },
|
||||
],
|
||||
betroffenenrechte: [
|
||||
{ blockId: 'betr-intro', blockType: 'introduction', sectionName: 'Einleitung Betroffenenrechte', targetWords: 120 },
|
||||
{ blockId: 'betr-conclusion', blockType: 'conclusion', sectionName: 'Fazit Betroffenenrechte', targetWords: 80 },
|
||||
],
|
||||
risikoanalyse: [
|
||||
{ blockId: 'risk-intro', blockType: 'introduction', sectionName: 'Einleitung Risikoanalyse', targetWords: 130 },
|
||||
{ blockId: 'risk-conclusion', blockType: 'conclusion', sectionName: 'Fazit Risikoanalyse', targetWords: 80 },
|
||||
],
|
||||
notfallplan: [
|
||||
{ blockId: 'notfall-intro', blockType: 'introduction', sectionName: 'Einleitung Notfallplan', targetWords: 120 },
|
||||
{ blockId: 'notfall-conclusion', blockType: 'conclusion', sectionName: 'Fazit Notfallplan', targetWords: 80 },
|
||||
],
|
||||
iace_ce_assessment: [
|
||||
{ blockId: 'iace-intro', blockType: 'introduction', sectionName: 'Einleitung IACE CE-Bewertung', targetWords: 150 },
|
||||
{ blockId: 'iace-appreciation', blockType: 'appreciation', sectionName: 'Wuerdigung bestehender Konformitaetsbewertung', targetWords: 60 },
|
||||
{ blockId: 'iace-conclusion', blockType: 'conclusion', sectionName: 'Fazit IACE CE-Bewertung', targetWords: 100 },
|
||||
],
|
||||
}
|
||||
|
||||
export function buildV2SystemPrompt(
|
||||
sanitizedFactsString: string,
|
||||
narrativeTagsString: string,
|
||||
terminologyString: string,
|
||||
styleString: string,
|
||||
disallowedString: string,
|
||||
companyName: string,
|
||||
blockId: string,
|
||||
blockType: string,
|
||||
sectionName: string,
|
||||
documentType: string,
|
||||
targetWords: number
|
||||
): string {
|
||||
return `Du bist ein Compliance-Dokumenten-Redakteur.
|
||||
Du schreibst einzelne Textabschnitte fuer offizielle Compliance-Dokumente.
|
||||
|
||||
KUNDENPROFIL (ERLAUBTE FAKTEN — nur diese darfst du verwenden):
|
||||
${sanitizedFactsString}
|
||||
|
||||
BEWERTUNGSERGEBNIS (sprachliche Tags — verwende nur diese Begriffe):
|
||||
${narrativeTagsString}
|
||||
|
||||
TERMINOLOGIE (verwende ausschliesslich diese Fachbegriffe):
|
||||
${terminologyString}
|
||||
|
||||
STIL:
|
||||
${styleString}
|
||||
|
||||
VERBOTENE INHALTE:
|
||||
${disallowedString}
|
||||
- Keine konkreten Prozentwerte, Scores oder Zahlen
|
||||
- Keine Compliance-Level-Bezeichnungen (L1, L2, L3, L4)
|
||||
- Keine direkte Ansprache ("Sie", "Ihr")
|
||||
- Kein Denglisch, keine Marketing-Sprache, keine Superlative
|
||||
|
||||
STRIKTE REGELN:
|
||||
1. Verwende den Firmennamen "${companyName}" — nie "Ihr Unternehmen"
|
||||
2. Schreibe in der dritten Person ("Die ${companyName}...")
|
||||
3. Beziehe dich auf die Branche und organisatorische Merkmale
|
||||
4. Verwende NUR Fakten aus dem Kundenprofil oben
|
||||
5. Verwende NUR die sprachlichen Tags aus dem Bewertungsergebnis
|
||||
6. Erfinde KEINE zusaetzlichen Fakten oder Bewertungen
|
||||
7. Halte dich an die Terminologie-Vorgaben
|
||||
8. Dein Text wird ZWISCHEN feste Datentabellen eingefuegt
|
||||
|
||||
OUTPUT-FORMAT: Antworte ausschliesslich als JSON:
|
||||
{
|
||||
"blockId": "${blockId}",
|
||||
"blockType": "${blockType}",
|
||||
"language": "de",
|
||||
"text": "...",
|
||||
"assertions": {
|
||||
"companyNameUsed": true/false,
|
||||
"industryReferenced": true/false,
|
||||
"structureReferenced": true/false,
|
||||
"itLandscapeReferenced": true/false,
|
||||
"narrativeTagsUsed": ["riskSummary", ...]
|
||||
},
|
||||
"forbiddenContentDetected": []
|
||||
}
|
||||
|
||||
DOKUMENTENTYP: ${documentType}
|
||||
SEKTION: ${sectionName}
|
||||
BLOCK-TYP: ${blockType}
|
||||
ZIEL-LAENGE: ${targetWords} Woerter`
|
||||
}
|
||||
|
||||
export function buildBlockSpecificPrompt(blockType: string, sectionName: string, documentType: string): string {
|
||||
switch (blockType) {
|
||||
case 'introduction':
|
||||
return `Schreibe eine Einleitung fuer das Dokument "${documentType}" (Sektion: ${sectionName}).
|
||||
Erklaere, warum dieses Dokument fuer das Unternehmen erstellt wurde.
|
||||
Gehe auf die spezifische Situation des Unternehmens ein.
|
||||
Erwaehne die Branche, die Organisationsform und die IT-Strategie.`
|
||||
case 'transition':
|
||||
return `Schreibe eine kurze Ueberleitung zur naechsten Sektion "${sectionName}".
|
||||
Verknuepfe den vorherigen Abschnitt logisch mit dem folgenden.`
|
||||
case 'conclusion':
|
||||
return `Schreibe einen abschliessenden Absatz fuer die Sektion "${sectionName}".
|
||||
Fasse die wesentlichen Punkte zusammen und verweise auf die fortlaufende Pflege.`
|
||||
case 'appreciation':
|
||||
return `Schreibe einen wertschaetzenden Satz ueber die bestehenden Massnahmen.
|
||||
Verwende dabei die sprachlichen Tags aus dem Bewertungsergebnis.
|
||||
Keine neuen Fakten erfinden — nur das Profil wuerdigen.`
|
||||
default:
|
||||
return `Schreibe einen Textabschnitt fuer "${sectionName}".`
|
||||
}
|
||||
}
|
||||
|
||||
export async function callOllama(systemPrompt: string, userPrompt: string): Promise<string> {
|
||||
const response = await fetch(`${OLLAMA_URL}/api/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: LLM_MODEL,
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: userPrompt },
|
||||
],
|
||||
stream: false,
|
||||
think: false,
|
||||
options: { temperature: 0.15, num_predict: 4096, num_ctx: 8192 },
|
||||
format: 'json',
|
||||
}),
|
||||
signal: AbortSignal.timeout(120000),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Ollama error: ${response.status}`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
return result.message?.content || ''
|
||||
}
|
||||
|
||||
export async function handleV2Draft(body: Record<string, unknown>): Promise<NextResponse> {
|
||||
const { documentType, draftContext, instructions } = body as {
|
||||
documentType: ScopeDocumentType
|
||||
draftContext: DraftContext
|
||||
instructions?: string
|
||||
}
|
||||
|
||||
const constraintCheck = constraintEnforcer.checkFromContext(documentType, draftContext)
|
||||
if (!constraintCheck.allowed) {
|
||||
return NextResponse.json({
|
||||
draft: null, constraintCheck, tokensUsed: 0,
|
||||
error: 'Constraint-Verletzung: ' + constraintCheck.violations.join('; '),
|
||||
}, { status: 403 })
|
||||
}
|
||||
|
||||
const scores = extractScoresFromDraftContext(draftContext)
|
||||
const narrativeTags: NarrativeTags = deriveNarrativeTags(scores)
|
||||
const allowedFacts = buildAllowedFactsFromDraftContext(draftContext, narrativeTags)
|
||||
|
||||
let sanitizationResult
|
||||
try {
|
||||
sanitizationResult = sanitizeAllowedFacts(allowedFacts)
|
||||
} catch (error) {
|
||||
if (error instanceof SanitizationError) {
|
||||
return NextResponse.json({
|
||||
error: `Sanitization Hard Abort: ${error.message} (Feld: ${error.field})`,
|
||||
draft: null, constraintCheck, tokensUsed: 0,
|
||||
}, { status: 422 })
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
const sanitizedFacts = sanitizationResult.facts
|
||||
const piiWarnings = validateNoRemainingPII(sanitizedFacts)
|
||||
if (piiWarnings.length > 0) console.warn('PII-Warnungen nach Sanitization:', piiWarnings)
|
||||
|
||||
const factsString = allowedFactsToPromptString(sanitizedFacts)
|
||||
const tagsString = narrativeTagsToPromptString(narrativeTags)
|
||||
const termsString = terminologyToPromptString()
|
||||
const styleString = styleContractToPromptString()
|
||||
const disallowedString = disallowedTopicsToPromptString()
|
||||
const promptHash = computeChecksumSync({ factsString, tagsString, termsString, styleString, disallowedString })
|
||||
|
||||
const v2RagCfg = DOCUMENT_RAG_CONFIG[documentType]
|
||||
const v2RagContext = await queryRAG(v2RagCfg.query, 3, v2RagCfg.collection)
|
||||
|
||||
const proseBlocks = DOCUMENT_PROSE_BLOCKS[documentType] || DOCUMENT_PROSE_BLOCKS.tom
|
||||
const generatedBlocks: ProseBlockOutput[] = []
|
||||
const repairAudits: RepairAudit[] = []
|
||||
let totalTokens = 0
|
||||
|
||||
for (const blockDef of proseBlocks) {
|
||||
const cacheParams: CacheKeyParams = {
|
||||
allowedFacts: sanitizedFacts, templateVersion: TEMPLATE_VERSION,
|
||||
terminologyVersion: TERMINOLOGY_VERSION, narrativeTags,
|
||||
promptHash, blockType: blockDef.blockType, sectionName: blockDef.sectionName,
|
||||
}
|
||||
|
||||
const cached = proseCache.getSync(cacheParams)
|
||||
if (cached) {
|
||||
generatedBlocks.push(cached)
|
||||
repairAudits.push({ repairAttempts: 0, validatorFailures: [], repairSuccessful: true, fallbackUsed: false })
|
||||
continue
|
||||
}
|
||||
|
||||
let systemPrompt = buildV2SystemPrompt(
|
||||
factsString, tagsString, termsString, styleString, disallowedString,
|
||||
sanitizedFacts.companyName, blockDef.blockId, blockDef.blockType,
|
||||
blockDef.sectionName, documentType, blockDef.targetWords
|
||||
)
|
||||
if (v2RagContext) systemPrompt += `\n\nRECHTSKONTEXT (als Referenz, nicht woertlich uebernehmen):\n${v2RagContext}`
|
||||
const userPrompt = buildBlockSpecificPrompt(blockDef.blockType, blockDef.sectionName, documentType)
|
||||
+ (instructions ? `\n\nZusaetzliche Anweisungen: ${instructions}` : '')
|
||||
|
||||
try {
|
||||
const rawOutput = await callOllama(systemPrompt, userPrompt)
|
||||
totalTokens += rawOutput.length / 4
|
||||
const { block, audit } = await executeRepairLoop(
|
||||
rawOutput, sanitizedFacts, narrativeTags, blockDef.blockId, blockDef.blockType,
|
||||
async (repairPrompt) => callOllama(systemPrompt, repairPrompt), documentType
|
||||
)
|
||||
generatedBlocks.push(block)
|
||||
repairAudits.push(audit)
|
||||
if (!audit.fallbackUsed) proseCache.setSync(cacheParams, block)
|
||||
} catch (error) {
|
||||
const { buildFallbackBlock } = await import('@/lib/sdk/drafting-engine/prose-validator')
|
||||
generatedBlocks.push(buildFallbackBlock(blockDef.blockId, blockDef.blockType, sanitizedFacts, documentType))
|
||||
repairAudits.push({
|
||||
repairAttempts: 0, validatorFailures: [[(error as Error).message]],
|
||||
repairSuccessful: false, fallbackUsed: true,
|
||||
fallbackReason: `LLM-Fehler: ${(error as Error).message}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const draftPrompt = buildPromptForDocumentType(documentType, draftContext, instructions)
|
||||
|
||||
let dataSections: DraftSection[] = []
|
||||
try {
|
||||
const dataResponse = await callOllama(V1_SYSTEM_PROMPT, draftPrompt)
|
||||
const parsed = JSON.parse(dataResponse)
|
||||
dataSections = (parsed.sections || []).map((s: Record<string, unknown>, i: number) => ({
|
||||
id: String(s.id || `section-${i}`), title: String(s.title || ''),
|
||||
content: String(s.content || ''), schemaField: s.schemaField ? String(s.schemaField) : undefined,
|
||||
}))
|
||||
totalTokens += dataResponse.length / 4
|
||||
} catch { dataSections = [] }
|
||||
|
||||
const introBlock = generatedBlocks.find(b => b.blockType === 'introduction')
|
||||
const transitionBlocks = generatedBlocks.filter(b => b.blockType === 'transition')
|
||||
const appreciationBlocks = generatedBlocks.filter(b => b.blockType === 'appreciation')
|
||||
const conclusionBlock = generatedBlocks.find(b => b.blockType === 'conclusion')
|
||||
|
||||
const mergedSections: DraftSection[] = []
|
||||
if (introBlock) mergedSections.push({ id: introBlock.blockId, title: 'Einleitung', content: introBlock.text })
|
||||
for (let i = 0; i < dataSections.length; i++) {
|
||||
if (i > 0 && transitionBlocks[i - 1]) mergedSections.push({ id: transitionBlocks[i - 1].blockId, title: '', content: transitionBlocks[i - 1].text })
|
||||
mergedSections.push(dataSections[i])
|
||||
}
|
||||
for (const block of appreciationBlocks) mergedSections.push({ id: block.blockId, title: 'Wuerdigung', content: block.text })
|
||||
if (conclusionBlock) mergedSections.push({ id: conclusionBlock.blockId, title: 'Fazit', content: conclusionBlock.text })
|
||||
|
||||
const finalSections = mergedSections.length > 0 ? mergedSections : generatedBlocks.map(b => ({
|
||||
id: b.blockId,
|
||||
title: b.blockType === 'introduction' ? 'Einleitung' : b.blockType === 'conclusion' ? 'Fazit' : b.blockType === 'appreciation' ? 'Wuerdigung' : 'Ueberleitung',
|
||||
content: b.text,
|
||||
}))
|
||||
|
||||
const draft: DraftRevision = {
|
||||
id: `draft-v2-${Date.now()}`,
|
||||
content: finalSections.map(s => s.title ? `## ${s.title}\n\n${s.content}` : s.content).join('\n\n'),
|
||||
sections: finalSections, createdAt: new Date().toISOString(), instruction: instructions,
|
||||
}
|
||||
|
||||
const auditTrail = {
|
||||
documentType, templateVersion: TEMPLATE_VERSION, terminologyVersion: TERMINOLOGY_VERSION,
|
||||
validatorVersion: VALIDATOR_VERSION, promptHash, llmModel: LLM_MODEL,
|
||||
llmTemperature: 0.15, llmProvider: 'ollama', narrativeTags,
|
||||
sanitization: sanitizationResult.audit, repairAudits,
|
||||
proseBlocks: generatedBlocks.map((b, i) => ({
|
||||
blockId: b.blockId, blockType: b.blockType,
|
||||
wordCount: b.text.split(/\s+/).filter(Boolean).length,
|
||||
fallbackUsed: repairAudits[i]?.fallbackUsed ?? false,
|
||||
repairAttempts: repairAudits[i]?.repairAttempts ?? 0,
|
||||
})),
|
||||
cacheStats: proseCache.getStats(),
|
||||
}
|
||||
|
||||
const truthLabel = { generation_mode: 'draft_assistance', truth_status: 'generated', may_be_used_as_evidence: false, generated_by: 'system' }
|
||||
|
||||
try {
|
||||
const BACKEND_URL = process.env.BACKEND_COMPLIANCE_URL || 'http://backend-compliance:8002'
|
||||
fetch(`${BACKEND_URL}/api/compliance/llm-audit`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
entity_type: 'document', entity_id: null, generation_mode: 'draft_assistance',
|
||||
truth_status: 'generated', may_be_used_as_evidence: false,
|
||||
llm_model: LLM_MODEL, llm_provider: 'ollama',
|
||||
input_summary: `${documentType} draft generation`,
|
||||
output_summary: draft?.sections?.length ? `${draft.sections.length} sections generated` : 'draft generated',
|
||||
}),
|
||||
}).catch(() => {/* fire-and-forget */})
|
||||
} catch { /* LLM audit persistence failure should not block the response */ }
|
||||
|
||||
return NextResponse.json({ draft, constraintCheck, tokensUsed: Math.round(totalTokens), pipelineVersion: 'v2', auditTrail, truthLabel })
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Drafting Engine - Draft Helper Functions (v1 pipeline + shared constants)
|
||||
*
|
||||
* Shared state, v1 legacy pipeline helpers.
|
||||
* v2 pipeline lives in draft-helpers-v2.ts.
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server'
|
||||
import { buildVVTDraftPrompt } from '@/lib/sdk/drafting-engine/prompts/draft-vvt'
|
||||
import { buildTOMDraftPrompt } from '@/lib/sdk/drafting-engine/prompts/draft-tom'
|
||||
import { buildDSFADraftPrompt } from '@/lib/sdk/drafting-engine/prompts/draft-dsfa'
|
||||
import { buildPrivacyPolicyDraftPrompt } from '@/lib/sdk/drafting-engine/prompts/draft-privacy-policy'
|
||||
import { buildLoeschfristenDraftPrompt } from '@/lib/sdk/drafting-engine/prompts/draft-loeschfristen'
|
||||
import type { DraftContext, DraftResponse, DraftRevision, DraftSection } from '@/lib/sdk/drafting-engine/types'
|
||||
import type { ScopeDocumentType } from '@/lib/sdk/compliance-scope-types'
|
||||
import { ConstraintEnforcer } from '@/lib/sdk/drafting-engine/constraint-enforcer'
|
||||
import { ProseCacheManager } from '@/lib/sdk/drafting-engine/cache'
|
||||
import { queryRAG } from '@/lib/sdk/drafting-engine/rag-query'
|
||||
import { DOCUMENT_RAG_CONFIG } from '@/lib/sdk/drafting-engine/rag-config'
|
||||
|
||||
const OLLAMA_URL = process.env.OLLAMA_URL || 'http://host.docker.internal:11434'
|
||||
const LLM_MODEL = process.env.COMPLIANCE_LLM_MODEL || 'qwen2.5vl:32b'
|
||||
|
||||
export const constraintEnforcer = new ConstraintEnforcer()
|
||||
export const proseCache = new ProseCacheManager({ maxEntries: 200, ttlHours: 24 })
|
||||
|
||||
export const TEMPLATE_VERSION = '2.0.0'
|
||||
export const TERMINOLOGY_VERSION = '1.0.0'
|
||||
export const VALIDATOR_VERSION = '1.0.0'
|
||||
|
||||
// ============================================================================
|
||||
// v1 Legacy Pipeline
|
||||
// ============================================================================
|
||||
|
||||
export const V1_SYSTEM_PROMPT = `Du bist ein DSGVO-Compliance-Experte und erstellst strukturierte Dokument-Entwuerfe.
|
||||
Du MUSST immer im JSON-Format antworten mit einem "sections" Array.
|
||||
Jede Section hat: id, title, content, schemaField.
|
||||
Halte die Tiefe strikt am vorgegebenen Level.
|
||||
Markiere fehlende Informationen mit [PLATZHALTER: Beschreibung].
|
||||
Sprache: Deutsch.`
|
||||
|
||||
export function buildPromptForDocumentType(
|
||||
documentType: ScopeDocumentType,
|
||||
context: DraftContext,
|
||||
instructions?: string
|
||||
): string {
|
||||
switch (documentType) {
|
||||
case 'vvt':
|
||||
return buildVVTDraftPrompt({ context, instructions })
|
||||
case 'tom':
|
||||
return buildTOMDraftPrompt({ context, instructions })
|
||||
case 'dsfa':
|
||||
return buildDSFADraftPrompt({ context, instructions })
|
||||
case 'dsi':
|
||||
return buildPrivacyPolicyDraftPrompt({ context, instructions })
|
||||
case 'lf':
|
||||
return buildLoeschfristenDraftPrompt({ context, instructions })
|
||||
default:
|
||||
return `## Aufgabe: Entwurf fuer ${documentType}
|
||||
|
||||
### Level: ${context.decisions.level}
|
||||
### Tiefe: ${context.constraints.depthRequirements.depth}
|
||||
### Erforderliche Inhalte:
|
||||
${context.constraints.depthRequirements.detailItems.map((item, i) => `${i + 1}. ${item}`).join('\n')}
|
||||
|
||||
${instructions ? `### Anweisungen: ${instructions}` : ''}
|
||||
|
||||
Antworte als JSON mit "sections" Array.`
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleV1Draft(body: Record<string, unknown>): Promise<NextResponse> {
|
||||
const { documentType, draftContext, instructions, existingDraft } = body as {
|
||||
documentType: ScopeDocumentType
|
||||
draftContext: DraftContext
|
||||
instructions?: string
|
||||
existingDraft?: DraftRevision
|
||||
}
|
||||
|
||||
const constraintCheck = constraintEnforcer.checkFromContext(documentType, draftContext)
|
||||
if (!constraintCheck.allowed) {
|
||||
return NextResponse.json({
|
||||
draft: null,
|
||||
constraintCheck,
|
||||
tokensUsed: 0,
|
||||
error: 'Constraint-Verletzung: ' + constraintCheck.violations.join('; '),
|
||||
}, { status: 403 })
|
||||
}
|
||||
|
||||
const ragCfg = DOCUMENT_RAG_CONFIG[documentType]
|
||||
const ragContext = await queryRAG(ragCfg.query, 3, ragCfg.collection)
|
||||
|
||||
let v1SystemPrompt = V1_SYSTEM_PROMPT
|
||||
if (ragContext) {
|
||||
v1SystemPrompt += `\n\n## Relevanter Rechtskontext\n${ragContext}`
|
||||
}
|
||||
|
||||
const draftPrompt = buildPromptForDocumentType(documentType, draftContext, instructions)
|
||||
const messages = [
|
||||
{ role: 'system', content: v1SystemPrompt },
|
||||
...(existingDraft ? [{
|
||||
role: 'assistant',
|
||||
content: `Bisheriger Entwurf:\n${JSON.stringify(existingDraft.sections, null, 2)}`,
|
||||
}] : []),
|
||||
{ role: 'user', content: draftPrompt },
|
||||
]
|
||||
|
||||
const ollamaResponse = await fetch(`${OLLAMA_URL}/api/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: LLM_MODEL,
|
||||
messages,
|
||||
stream: false,
|
||||
think: false,
|
||||
options: { temperature: 0.15, num_predict: 16384, num_ctx: 8192 },
|
||||
format: 'json',
|
||||
}),
|
||||
signal: AbortSignal.timeout(180000),
|
||||
})
|
||||
|
||||
if (!ollamaResponse.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: `LLM nicht erreichbar (Status ${ollamaResponse.status})` },
|
||||
{ status: 502 }
|
||||
)
|
||||
}
|
||||
|
||||
const result = await ollamaResponse.json()
|
||||
const content = result.message?.content || ''
|
||||
|
||||
let sections: DraftSection[] = []
|
||||
try {
|
||||
const parsed = JSON.parse(content)
|
||||
sections = (parsed.sections || []).map((s: Record<string, unknown>, i: number) => ({
|
||||
id: String(s.id || `section-${i}`),
|
||||
title: String(s.title || ''),
|
||||
content: String(s.content || ''),
|
||||
schemaField: s.schemaField ? String(s.schemaField) : undefined,
|
||||
}))
|
||||
} catch {
|
||||
sections = [{ id: 'raw', title: 'Entwurf', content }]
|
||||
}
|
||||
|
||||
const draft: DraftRevision = {
|
||||
id: `draft-${Date.now()}`,
|
||||
content: sections.map(s => `## ${s.title}\n\n${s.content}`).join('\n\n'),
|
||||
sections,
|
||||
createdAt: new Date().toISOString(),
|
||||
instruction: instructions as string | undefined,
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
draft,
|
||||
constraintCheck,
|
||||
tokensUsed: result.eval_count || 0,
|
||||
} satisfies DraftResponse)
|
||||
}
|
||||
|
||||
// Re-export v2 handler for route.ts (backward compat — single import point)
|
||||
export { handleV2Draft } from './draft-helpers-v2'
|
||||
@@ -9,555 +9,7 @@
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const OLLAMA_URL = process.env.OLLAMA_URL || 'http://host.docker.internal:11434'
|
||||
const LLM_MODEL = process.env.COMPLIANCE_LLM_MODEL || 'qwen2.5vl:32b'
|
||||
|
||||
// v1 imports (Legacy)
|
||||
import { buildVVTDraftPrompt } from '@/lib/sdk/drafting-engine/prompts/draft-vvt'
|
||||
import { buildTOMDraftPrompt } from '@/lib/sdk/drafting-engine/prompts/draft-tom'
|
||||
import { buildDSFADraftPrompt } from '@/lib/sdk/drafting-engine/prompts/draft-dsfa'
|
||||
import { buildPrivacyPolicyDraftPrompt } from '@/lib/sdk/drafting-engine/prompts/draft-privacy-policy'
|
||||
import { buildLoeschfristenDraftPrompt } from '@/lib/sdk/drafting-engine/prompts/draft-loeschfristen'
|
||||
import type { DraftContext, DraftResponse, DraftRevision, DraftSection } from '@/lib/sdk/drafting-engine/types'
|
||||
import type { ScopeDocumentType } from '@/lib/sdk/compliance-scope-types'
|
||||
import { ConstraintEnforcer } from '@/lib/sdk/drafting-engine/constraint-enforcer'
|
||||
|
||||
// v2 imports (Personalisierte Pipeline)
|
||||
import { deriveNarrativeTags, extractScoresFromDraftContext, narrativeTagsToPromptString } from '@/lib/sdk/drafting-engine/narrative-tags'
|
||||
import type { NarrativeTags } from '@/lib/sdk/drafting-engine/narrative-tags'
|
||||
import { buildAllowedFactsFromDraftContext, allowedFactsToPromptString, disallowedTopicsToPromptString } from '@/lib/sdk/drafting-engine/allowed-facts-v2'
|
||||
import { sanitizeAllowedFacts, validateNoRemainingPII, SanitizationError } from '@/lib/sdk/drafting-engine/sanitizer'
|
||||
import { terminologyToPromptString, styleContractToPromptString } from '@/lib/sdk/drafting-engine/terminology'
|
||||
import { executeRepairLoop, type ProseBlockOutput, type RepairAudit } from '@/lib/sdk/drafting-engine/prose-validator'
|
||||
import { ProseCacheManager, computeChecksumSync, type CacheKeyParams } from '@/lib/sdk/drafting-engine/cache'
|
||||
|
||||
// ============================================================================
|
||||
// Shared State
|
||||
// ============================================================================
|
||||
|
||||
const constraintEnforcer = new ConstraintEnforcer()
|
||||
const proseCache = new ProseCacheManager({ maxEntries: 200, ttlHours: 24 })
|
||||
|
||||
// Template/Terminology Versionen (fuer Cache-Key)
|
||||
const TEMPLATE_VERSION = '2.0.0'
|
||||
const TERMINOLOGY_VERSION = '1.0.0'
|
||||
const VALIDATOR_VERSION = '1.0.0'
|
||||
|
||||
// ============================================================================
|
||||
// v1 Legacy Pipeline
|
||||
// ============================================================================
|
||||
|
||||
const V1_SYSTEM_PROMPT = `Du bist ein DSGVO-Compliance-Experte und erstellst strukturierte Dokument-Entwuerfe.
|
||||
Du MUSST immer im JSON-Format antworten mit einem "sections" Array.
|
||||
Jede Section hat: id, title, content, schemaField.
|
||||
Halte die Tiefe strikt am vorgegebenen Level.
|
||||
Markiere fehlende Informationen mit [PLATZHALTER: Beschreibung].
|
||||
Sprache: Deutsch.`
|
||||
|
||||
function buildPromptForDocumentType(
|
||||
documentType: ScopeDocumentType,
|
||||
context: DraftContext,
|
||||
instructions?: string
|
||||
): string {
|
||||
switch (documentType) {
|
||||
case 'vvt':
|
||||
return buildVVTDraftPrompt({ context, instructions })
|
||||
case 'tom':
|
||||
return buildTOMDraftPrompt({ context, instructions })
|
||||
case 'dsfa':
|
||||
return buildDSFADraftPrompt({ context, instructions })
|
||||
case 'dsi':
|
||||
return buildPrivacyPolicyDraftPrompt({ context, instructions })
|
||||
case 'lf':
|
||||
return buildLoeschfristenDraftPrompt({ context, instructions })
|
||||
default:
|
||||
return `## Aufgabe: Entwurf fuer ${documentType}
|
||||
|
||||
### Level: ${context.decisions.level}
|
||||
### Tiefe: ${context.constraints.depthRequirements.depth}
|
||||
### Erforderliche Inhalte:
|
||||
${context.constraints.depthRequirements.detailItems.map((item, i) => `${i + 1}. ${item}`).join('\n')}
|
||||
|
||||
${instructions ? `### Anweisungen: ${instructions}` : ''}
|
||||
|
||||
Antworte als JSON mit "sections" Array.`
|
||||
}
|
||||
}
|
||||
|
||||
async function handleV1Draft(body: Record<string, unknown>): Promise<NextResponse> {
|
||||
const { documentType, draftContext, instructions, existingDraft } = body as {
|
||||
documentType: ScopeDocumentType
|
||||
draftContext: DraftContext
|
||||
instructions?: string
|
||||
existingDraft?: DraftRevision
|
||||
}
|
||||
|
||||
const constraintCheck = constraintEnforcer.checkFromContext(documentType, draftContext)
|
||||
if (!constraintCheck.allowed) {
|
||||
return NextResponse.json({
|
||||
draft: null,
|
||||
constraintCheck,
|
||||
tokensUsed: 0,
|
||||
error: 'Constraint-Verletzung: ' + constraintCheck.violations.join('; '),
|
||||
}, { status: 403 })
|
||||
}
|
||||
|
||||
const draftPrompt = buildPromptForDocumentType(documentType, draftContext, instructions)
|
||||
const messages = [
|
||||
{ role: 'system', content: V1_SYSTEM_PROMPT },
|
||||
...(existingDraft ? [{
|
||||
role: 'assistant',
|
||||
content: `Bisheriger Entwurf:\n${JSON.stringify(existingDraft.sections, null, 2)}`,
|
||||
}] : []),
|
||||
{ role: 'user', content: draftPrompt },
|
||||
]
|
||||
|
||||
const ollamaResponse = await fetch(`${OLLAMA_URL}/api/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: LLM_MODEL,
|
||||
messages,
|
||||
stream: false,
|
||||
options: { temperature: 0.15, num_predict: 16384 },
|
||||
format: 'json',
|
||||
}),
|
||||
signal: AbortSignal.timeout(180000),
|
||||
})
|
||||
|
||||
if (!ollamaResponse.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: `LLM nicht erreichbar (Status ${ollamaResponse.status})` },
|
||||
{ status: 502 }
|
||||
)
|
||||
}
|
||||
|
||||
const result = await ollamaResponse.json()
|
||||
const content = result.message?.content || ''
|
||||
|
||||
let sections: DraftSection[] = []
|
||||
try {
|
||||
const parsed = JSON.parse(content)
|
||||
sections = (parsed.sections || []).map((s: Record<string, unknown>, i: number) => ({
|
||||
id: String(s.id || `section-${i}`),
|
||||
title: String(s.title || ''),
|
||||
content: String(s.content || ''),
|
||||
schemaField: s.schemaField ? String(s.schemaField) : undefined,
|
||||
}))
|
||||
} catch {
|
||||
sections = [{ id: 'raw', title: 'Entwurf', content }]
|
||||
}
|
||||
|
||||
const draft: DraftRevision = {
|
||||
id: `draft-${Date.now()}`,
|
||||
content: sections.map(s => `## ${s.title}\n\n${s.content}`).join('\n\n'),
|
||||
sections,
|
||||
createdAt: new Date().toISOString(),
|
||||
instruction: instructions as string | undefined,
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
draft,
|
||||
constraintCheck,
|
||||
tokensUsed: result.eval_count || 0,
|
||||
} satisfies DraftResponse)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// v2 Personalisierte Pipeline
|
||||
// ============================================================================
|
||||
|
||||
/** Prose block definitions per document type */
|
||||
const DOCUMENT_PROSE_BLOCKS: Record<string, Array<{ blockId: string; blockType: ProseBlockOutput['blockType']; sectionName: string; targetWords: number }>> = {
|
||||
tom: [
|
||||
{ blockId: 'tom-intro', blockType: 'introduction', sectionName: 'Einleitung TOM', targetWords: 120 },
|
||||
{ blockId: 'tom-transition', blockType: 'transition', sectionName: 'Ueberleitung Massnahmen', targetWords: 40 },
|
||||
{ blockId: 'tom-conclusion', blockType: 'conclusion', sectionName: 'Fazit TOM', targetWords: 80 },
|
||||
],
|
||||
dsfa: [
|
||||
{ blockId: 'dsfa-intro', blockType: 'introduction', sectionName: 'Einleitung DSFA', targetWords: 150 },
|
||||
{ blockId: 'dsfa-transition', blockType: 'transition', sectionName: 'Ueberleitung Risikobewertung', targetWords: 40 },
|
||||
{ blockId: 'dsfa-appreciation', blockType: 'appreciation', sectionName: 'Wuerdigung bestehender Massnahmen', targetWords: 60 },
|
||||
{ blockId: 'dsfa-conclusion', blockType: 'conclusion', sectionName: 'Fazit DSFA', targetWords: 100 },
|
||||
],
|
||||
vvt: [
|
||||
{ blockId: 'vvt-intro', blockType: 'introduction', sectionName: 'Einleitung VVT', targetWords: 120 },
|
||||
{ blockId: 'vvt-conclusion', blockType: 'conclusion', sectionName: 'Fazit VVT', targetWords: 80 },
|
||||
],
|
||||
dsi: [
|
||||
{ blockId: 'dsi-intro', blockType: 'introduction', sectionName: 'Einleitung Datenschutzerklaerung', targetWords: 130 },
|
||||
{ blockId: 'dsi-conclusion', blockType: 'conclusion', sectionName: 'Fazit Datenschutzerklaerung', targetWords: 80 },
|
||||
],
|
||||
lf: [
|
||||
{ blockId: 'lf-intro', blockType: 'introduction', sectionName: 'Einleitung Loeschfristen', targetWords: 100 },
|
||||
{ blockId: 'lf-conclusion', blockType: 'conclusion', sectionName: 'Fazit Loeschfristen', targetWords: 60 },
|
||||
],
|
||||
}
|
||||
|
||||
function buildV2SystemPrompt(
|
||||
sanitizedFactsString: string,
|
||||
narrativeTagsString: string,
|
||||
terminologyString: string,
|
||||
styleString: string,
|
||||
disallowedString: string,
|
||||
companyName: string,
|
||||
blockId: string,
|
||||
blockType: string,
|
||||
sectionName: string,
|
||||
documentType: string,
|
||||
targetWords: number
|
||||
): string {
|
||||
return `Du bist ein Compliance-Dokumenten-Redakteur.
|
||||
Du schreibst einzelne Textabschnitte fuer offizielle Compliance-Dokumente.
|
||||
|
||||
KUNDENPROFIL (ERLAUBTE FAKTEN — nur diese darfst du verwenden):
|
||||
${sanitizedFactsString}
|
||||
|
||||
BEWERTUNGSERGEBNIS (sprachliche Tags — verwende nur diese Begriffe):
|
||||
${narrativeTagsString}
|
||||
|
||||
TERMINOLOGIE (verwende ausschliesslich diese Fachbegriffe):
|
||||
${terminologyString}
|
||||
|
||||
STIL:
|
||||
${styleString}
|
||||
|
||||
VERBOTENE INHALTE:
|
||||
${disallowedString}
|
||||
- Keine konkreten Prozentwerte, Scores oder Zahlen
|
||||
- Keine Compliance-Level-Bezeichnungen (L1, L2, L3, L4)
|
||||
- Keine direkte Ansprache ("Sie", "Ihr")
|
||||
- Kein Denglisch, keine Marketing-Sprache, keine Superlative
|
||||
|
||||
STRIKTE REGELN:
|
||||
1. Verwende den Firmennamen "${companyName}" — nie "Ihr Unternehmen"
|
||||
2. Schreibe in der dritten Person ("Die ${companyName}...")
|
||||
3. Beziehe dich auf die Branche und organisatorische Merkmale
|
||||
4. Verwende NUR Fakten aus dem Kundenprofil oben
|
||||
5. Verwende NUR die sprachlichen Tags aus dem Bewertungsergebnis
|
||||
6. Erfinde KEINE zusaetzlichen Fakten oder Bewertungen
|
||||
7. Halte dich an die Terminologie-Vorgaben
|
||||
8. Dein Text wird ZWISCHEN feste Datentabellen eingefuegt
|
||||
|
||||
OUTPUT-FORMAT: Antworte ausschliesslich als JSON:
|
||||
{
|
||||
"blockId": "${blockId}",
|
||||
"blockType": "${blockType}",
|
||||
"language": "de",
|
||||
"text": "...",
|
||||
"assertions": {
|
||||
"companyNameUsed": true/false,
|
||||
"industryReferenced": true/false,
|
||||
"structureReferenced": true/false,
|
||||
"itLandscapeReferenced": true/false,
|
||||
"narrativeTagsUsed": ["riskSummary", ...]
|
||||
},
|
||||
"forbiddenContentDetected": []
|
||||
}
|
||||
|
||||
DOKUMENTENTYP: ${documentType}
|
||||
SEKTION: ${sectionName}
|
||||
BLOCK-TYP: ${blockType}
|
||||
ZIEL-LAENGE: ${targetWords} Woerter`
|
||||
}
|
||||
|
||||
function buildBlockSpecificPrompt(blockType: string, sectionName: string, documentType: string): string {
|
||||
switch (blockType) {
|
||||
case 'introduction':
|
||||
return `Schreibe eine Einleitung fuer das Dokument "${documentType}" (Sektion: ${sectionName}).
|
||||
Erklaere, warum dieses Dokument fuer das Unternehmen erstellt wurde.
|
||||
Gehe auf die spezifische Situation des Unternehmens ein.
|
||||
Erwaehne die Branche, die Organisationsform und die IT-Strategie.`
|
||||
case 'transition':
|
||||
return `Schreibe eine kurze Ueberleitung zur naechsten Sektion "${sectionName}".
|
||||
Verknuepfe den vorherigen Abschnitt logisch mit dem folgenden.`
|
||||
case 'conclusion':
|
||||
return `Schreibe einen abschliessenden Absatz fuer die Sektion "${sectionName}".
|
||||
Fasse die wesentlichen Punkte zusammen und verweise auf die fortlaufende Pflege.`
|
||||
case 'appreciation':
|
||||
return `Schreibe einen wertschaetzenden Satz ueber die bestehenden Massnahmen.
|
||||
Verwende dabei die sprachlichen Tags aus dem Bewertungsergebnis.
|
||||
Keine neuen Fakten erfinden — nur das Profil wuerdigen.`
|
||||
default:
|
||||
return `Schreibe einen Textabschnitt fuer "${sectionName}".`
|
||||
}
|
||||
}
|
||||
|
||||
async function callOllama(systemPrompt: string, userPrompt: string): Promise<string> {
|
||||
const response = await fetch(`${OLLAMA_URL}/api/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: LLM_MODEL,
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: userPrompt },
|
||||
],
|
||||
stream: false,
|
||||
options: { temperature: 0.15, num_predict: 4096 },
|
||||
format: 'json',
|
||||
}),
|
||||
signal: AbortSignal.timeout(120000),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Ollama error: ${response.status}`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
return result.message?.content || ''
|
||||
}
|
||||
|
||||
async function handleV2Draft(body: Record<string, unknown>): Promise<NextResponse> {
|
||||
const { documentType, draftContext, instructions } = body as {
|
||||
documentType: ScopeDocumentType
|
||||
draftContext: DraftContext
|
||||
instructions?: string
|
||||
}
|
||||
|
||||
// Step 1: Constraint Check (Hard Gate)
|
||||
const constraintCheck = constraintEnforcer.checkFromContext(documentType, draftContext)
|
||||
if (!constraintCheck.allowed) {
|
||||
return NextResponse.json({
|
||||
draft: null,
|
||||
constraintCheck,
|
||||
tokensUsed: 0,
|
||||
error: 'Constraint-Verletzung: ' + constraintCheck.violations.join('; '),
|
||||
}, { status: 403 })
|
||||
}
|
||||
|
||||
// Step 2: Derive Narrative Tags (deterministisch)
|
||||
const scores = extractScoresFromDraftContext(draftContext)
|
||||
const narrativeTags: NarrativeTags = deriveNarrativeTags(scores)
|
||||
|
||||
// Step 3: Build Allowed Facts
|
||||
const allowedFacts = buildAllowedFactsFromDraftContext(draftContext, narrativeTags)
|
||||
|
||||
// Step 4: PII Sanitization
|
||||
let sanitizationResult
|
||||
try {
|
||||
sanitizationResult = sanitizeAllowedFacts(allowedFacts)
|
||||
} catch (error) {
|
||||
if (error instanceof SanitizationError) {
|
||||
return NextResponse.json({
|
||||
error: `Sanitization Hard Abort: ${error.message} (Feld: ${error.field})`,
|
||||
draft: null,
|
||||
constraintCheck,
|
||||
tokensUsed: 0,
|
||||
}, { status: 422 })
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
const sanitizedFacts = sanitizationResult.facts
|
||||
|
||||
// Verify no remaining PII
|
||||
const piiWarnings = validateNoRemainingPII(sanitizedFacts)
|
||||
if (piiWarnings.length > 0) {
|
||||
console.warn('PII-Warnungen nach Sanitization:', piiWarnings)
|
||||
}
|
||||
|
||||
// Step 5: Build prompt components
|
||||
const factsString = allowedFactsToPromptString(sanitizedFacts)
|
||||
const tagsString = narrativeTagsToPromptString(narrativeTags)
|
||||
const termsString = terminologyToPromptString()
|
||||
const styleString = styleContractToPromptString()
|
||||
const disallowedString = disallowedTopicsToPromptString()
|
||||
|
||||
// Compute prompt hash for audit
|
||||
const promptHash = computeChecksumSync({ factsString, tagsString, termsString, styleString, disallowedString })
|
||||
|
||||
// Step 6: Generate Prose Blocks (with cache + repair loop)
|
||||
const proseBlocks = DOCUMENT_PROSE_BLOCKS[documentType] || DOCUMENT_PROSE_BLOCKS.tom
|
||||
const generatedBlocks: ProseBlockOutput[] = []
|
||||
const repairAudits: RepairAudit[] = []
|
||||
let totalTokens = 0
|
||||
|
||||
for (const blockDef of proseBlocks) {
|
||||
// Check cache
|
||||
const cacheParams: CacheKeyParams = {
|
||||
allowedFacts: sanitizedFacts,
|
||||
templateVersion: TEMPLATE_VERSION,
|
||||
terminologyVersion: TERMINOLOGY_VERSION,
|
||||
narrativeTags,
|
||||
promptHash,
|
||||
blockType: blockDef.blockType,
|
||||
sectionName: blockDef.sectionName,
|
||||
}
|
||||
|
||||
const cached = proseCache.getSync(cacheParams)
|
||||
if (cached) {
|
||||
generatedBlocks.push(cached)
|
||||
repairAudits.push({
|
||||
repairAttempts: 0,
|
||||
validatorFailures: [],
|
||||
repairSuccessful: true,
|
||||
fallbackUsed: false,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Build prompts
|
||||
const systemPrompt = buildV2SystemPrompt(
|
||||
factsString, tagsString, termsString, styleString, disallowedString,
|
||||
sanitizedFacts.companyName,
|
||||
blockDef.blockId, blockDef.blockType, blockDef.sectionName,
|
||||
documentType, blockDef.targetWords
|
||||
)
|
||||
const userPrompt = buildBlockSpecificPrompt(
|
||||
blockDef.blockType, blockDef.sectionName, documentType
|
||||
) + (instructions ? `\n\nZusaetzliche Anweisungen: ${instructions}` : '')
|
||||
|
||||
// Call LLM + Repair Loop
|
||||
try {
|
||||
const rawOutput = await callOllama(systemPrompt, userPrompt)
|
||||
totalTokens += rawOutput.length / 4 // Rough token estimate
|
||||
|
||||
const { block, audit } = await executeRepairLoop(
|
||||
rawOutput,
|
||||
sanitizedFacts,
|
||||
narrativeTags,
|
||||
blockDef.blockId,
|
||||
blockDef.blockType,
|
||||
async (repairPrompt) => callOllama(systemPrompt, repairPrompt),
|
||||
documentType
|
||||
)
|
||||
|
||||
generatedBlocks.push(block)
|
||||
repairAudits.push(audit)
|
||||
|
||||
// Cache successful blocks (not fallbacks)
|
||||
if (!audit.fallbackUsed) {
|
||||
proseCache.setSync(cacheParams, block)
|
||||
}
|
||||
} catch (error) {
|
||||
// LLM unreachable → Fallback
|
||||
const { buildFallbackBlock } = await import('@/lib/sdk/drafting-engine/prose-validator')
|
||||
generatedBlocks.push(
|
||||
buildFallbackBlock(blockDef.blockId, blockDef.blockType, sanitizedFacts, documentType)
|
||||
)
|
||||
repairAudits.push({
|
||||
repairAttempts: 0,
|
||||
validatorFailures: [[(error as Error).message]],
|
||||
repairSuccessful: false,
|
||||
fallbackUsed: true,
|
||||
fallbackReason: `LLM-Fehler: ${(error as Error).message}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Step 7: Build v1-compatible draft sections from prose blocks + original prompt
|
||||
const draftPrompt = buildPromptForDocumentType(documentType, draftContext, instructions)
|
||||
|
||||
// Also generate data sections via legacy pipeline
|
||||
let dataSections: DraftSection[] = []
|
||||
try {
|
||||
const dataResponse = await callOllama(V1_SYSTEM_PROMPT, draftPrompt)
|
||||
const parsed = JSON.parse(dataResponse)
|
||||
dataSections = (parsed.sections || []).map((s: Record<string, unknown>, i: number) => ({
|
||||
id: String(s.id || `section-${i}`),
|
||||
title: String(s.title || ''),
|
||||
content: String(s.content || ''),
|
||||
schemaField: s.schemaField ? String(s.schemaField) : undefined,
|
||||
}))
|
||||
totalTokens += dataResponse.length / 4
|
||||
} catch {
|
||||
dataSections = []
|
||||
}
|
||||
|
||||
// Merge: Prose intro → Data sections → Prose transitions/conclusion
|
||||
const introBlock = generatedBlocks.find(b => b.blockType === 'introduction')
|
||||
const transitionBlocks = generatedBlocks.filter(b => b.blockType === 'transition')
|
||||
const appreciationBlocks = generatedBlocks.filter(b => b.blockType === 'appreciation')
|
||||
const conclusionBlock = generatedBlocks.find(b => b.blockType === 'conclusion')
|
||||
|
||||
const mergedSections: DraftSection[] = []
|
||||
|
||||
if (introBlock) {
|
||||
mergedSections.push({
|
||||
id: introBlock.blockId,
|
||||
title: 'Einleitung',
|
||||
content: introBlock.text,
|
||||
})
|
||||
}
|
||||
|
||||
for (let i = 0; i < dataSections.length; i++) {
|
||||
// Insert transition before data section (if available)
|
||||
if (i > 0 && transitionBlocks[i - 1]) {
|
||||
mergedSections.push({
|
||||
id: transitionBlocks[i - 1].blockId,
|
||||
title: '',
|
||||
content: transitionBlocks[i - 1].text,
|
||||
})
|
||||
}
|
||||
mergedSections.push(dataSections[i])
|
||||
}
|
||||
|
||||
for (const block of appreciationBlocks) {
|
||||
mergedSections.push({
|
||||
id: block.blockId,
|
||||
title: 'Wuerdigung',
|
||||
content: block.text,
|
||||
})
|
||||
}
|
||||
|
||||
if (conclusionBlock) {
|
||||
mergedSections.push({
|
||||
id: conclusionBlock.blockId,
|
||||
title: 'Fazit',
|
||||
content: conclusionBlock.text,
|
||||
})
|
||||
}
|
||||
|
||||
// If no data sections generated, use prose blocks as sections
|
||||
const finalSections = mergedSections.length > 0 ? mergedSections : generatedBlocks.map(b => ({
|
||||
id: b.blockId,
|
||||
title: b.blockType === 'introduction' ? 'Einleitung' :
|
||||
b.blockType === 'conclusion' ? 'Fazit' :
|
||||
b.blockType === 'appreciation' ? 'Wuerdigung' : 'Ueberleitung',
|
||||
content: b.text,
|
||||
}))
|
||||
|
||||
const draft: DraftRevision = {
|
||||
id: `draft-v2-${Date.now()}`,
|
||||
content: finalSections.map(s => s.title ? `## ${s.title}\n\n${s.content}` : s.content).join('\n\n'),
|
||||
sections: finalSections,
|
||||
createdAt: new Date().toISOString(),
|
||||
instruction: instructions,
|
||||
}
|
||||
|
||||
// Step 8: Build Audit Trail
|
||||
const auditTrail = {
|
||||
documentType,
|
||||
templateVersion: TEMPLATE_VERSION,
|
||||
terminologyVersion: TERMINOLOGY_VERSION,
|
||||
validatorVersion: VALIDATOR_VERSION,
|
||||
promptHash,
|
||||
llmModel: LLM_MODEL,
|
||||
llmTemperature: 0.15,
|
||||
llmProvider: 'ollama',
|
||||
narrativeTags,
|
||||
sanitization: sanitizationResult.audit,
|
||||
repairAudits,
|
||||
proseBlocks: generatedBlocks.map((b, i) => ({
|
||||
blockId: b.blockId,
|
||||
blockType: b.blockType,
|
||||
wordCount: b.text.split(/\s+/).filter(Boolean).length,
|
||||
fallbackUsed: repairAudits[i]?.fallbackUsed ?? false,
|
||||
repairAttempts: repairAudits[i]?.repairAttempts ?? 0,
|
||||
})),
|
||||
cacheStats: proseCache.getStats(),
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
draft,
|
||||
constraintCheck,
|
||||
tokensUsed: Math.round(totalTokens),
|
||||
pipelineVersion: 'v2',
|
||||
auditTrail,
|
||||
})
|
||||
}
|
||||
import { handleV1Draft, handleV2Draft } from './draft-helpers'
|
||||
|
||||
// ============================================================================
|
||||
// Route Handler
|
||||
|
||||
@@ -14,6 +14,76 @@ import { buildCrossCheckPrompt } from '@/lib/sdk/drafting-engine/prompts/validat
|
||||
const OLLAMA_URL = process.env.OLLAMA_URL || 'http://host.docker.internal:11434'
|
||||
const LLM_MODEL = process.env.COMPLIANCE_LLM_MODEL || 'qwen2.5vl:32b'
|
||||
|
||||
/**
|
||||
* Anti-Fake-Evidence: Verbotene Formulierungen
|
||||
*
|
||||
* Flags formulations that falsely claim compliance without evidence.
|
||||
* Only allowed when: control_status=pass AND confidence >= E2 AND
|
||||
* truth_status in (validated_internal, accepted_by_auditor).
|
||||
*/
|
||||
interface EvidenceContext {
|
||||
controlStatus?: string
|
||||
confidenceLevel?: string
|
||||
truthStatus?: string
|
||||
}
|
||||
|
||||
const FORBIDDEN_PATTERNS: Array<{
|
||||
pattern: RegExp
|
||||
label: string
|
||||
safeAlternative: string
|
||||
}> = [
|
||||
{ pattern: /ist\s+compliant/gi, label: 'ist compliant', safeAlternative: 'soll compliant sein' },
|
||||
{ pattern: /erfüllt\s+vollständig/gi, label: 'erfüllt vollständig', safeAlternative: 'soll vollständig erfüllt werden' },
|
||||
{ pattern: /wurde\s+geprüft/gi, label: 'wurde geprüft', safeAlternative: 'soll geprüft werden' },
|
||||
{ pattern: /wurde\s+umgesetzt/gi, label: 'wurde umgesetzt', safeAlternative: 'ist zur Umsetzung vorgesehen' },
|
||||
{ pattern: /ist\s+auditiert/gi, label: 'ist auditiert', safeAlternative: 'soll auditiert werden' },
|
||||
{ pattern: /vollständig\s+implementiert/gi, label: 'vollständig implementiert', safeAlternative: 'Implementierung ist vorgesehen' },
|
||||
{ pattern: /nachweislich\s+konform/gi, label: 'nachweislich konform', safeAlternative: 'Konformität ist nachzuweisen' },
|
||||
]
|
||||
|
||||
const CONFIDENCE_ORDER: Record<string, number> = { E0: 0, E1: 1, E2: 2, E3: 3, E4: 4 }
|
||||
const VALID_TRUTH_STATUSES = new Set(['validated_internal', 'accepted_by_auditor'])
|
||||
|
||||
function checkForbiddenFormulations(
|
||||
content: string,
|
||||
evidenceContext?: EvidenceContext,
|
||||
): ValidationFinding[] {
|
||||
const findings: ValidationFinding[] = []
|
||||
|
||||
if (!content) return findings
|
||||
|
||||
// If evidence context shows sufficient proof, allow the formulations
|
||||
if (evidenceContext) {
|
||||
const { controlStatus, confidenceLevel, truthStatus } = evidenceContext
|
||||
const confLevel = CONFIDENCE_ORDER[confidenceLevel ?? 'E0'] ?? 0
|
||||
if (
|
||||
controlStatus === 'pass' &&
|
||||
confLevel >= CONFIDENCE_ORDER.E2 &&
|
||||
VALID_TRUTH_STATUSES.has(truthStatus ?? '')
|
||||
) {
|
||||
return findings // Formulations are backed by real evidence
|
||||
}
|
||||
}
|
||||
|
||||
for (const { pattern, label, safeAlternative } of FORBIDDEN_PATTERNS) {
|
||||
// Reset regex state for global patterns
|
||||
pattern.lastIndex = 0
|
||||
if (pattern.test(content)) {
|
||||
findings.push({
|
||||
id: `AFE-FORBIDDEN-${label.replace(/\s+/g, '_').toUpperCase()}`,
|
||||
severity: 'error',
|
||||
category: 'forbidden_formulation' as ValidationFinding['category'],
|
||||
title: `Verbotene Formulierung: "${label}"`,
|
||||
description: `Die Formulierung "${label}" impliziert eine nachgewiesene Compliance, die ohne ausreichenden Nachweis (Evidence >= E2, validiert) nicht verwendet werden darf.`,
|
||||
documentType: 'vvt' as ScopeDocumentType,
|
||||
suggestion: `Verwende stattdessen: "${safeAlternative}"`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return findings
|
||||
}
|
||||
|
||||
/**
|
||||
* Stufe 1: Deterministische Pruefung
|
||||
*/
|
||||
@@ -84,6 +154,66 @@ function deterministicCheck(
|
||||
})
|
||||
}
|
||||
|
||||
// Check 5: DSFA ohne VVT-Grundlage
|
||||
if (documentType === 'dsfa' && validationContext.crossReferences.vvtCategories.length === 0) {
|
||||
findings.push({
|
||||
id: 'DET-DSFA-NO-VVT',
|
||||
severity: 'error',
|
||||
category: 'cross_reference',
|
||||
title: 'DSFA ohne VVT-Grundlage',
|
||||
description: 'Eine DSFA setzt ein Verarbeitungsverzeichnis voraus. Ohne VVT fehlt die Uebersicht ueber die betroffenen Verarbeitungstaetigkeiten.',
|
||||
documentType: 'dsfa',
|
||||
crossReferenceType: 'vvt',
|
||||
legalReference: 'Art. 35 i.V.m. Art. 30 DSGVO',
|
||||
suggestion: 'Zuerst ein VVT erstellen, dann die DSFA darauf aufbauen.',
|
||||
})
|
||||
}
|
||||
|
||||
// Check 6: DSFA ohne TOM-Massnahmen
|
||||
if (documentType === 'dsfa' && validationContext.crossReferences.tomControls.length === 0) {
|
||||
findings.push({
|
||||
id: 'DET-DSFA-NO-TOM',
|
||||
severity: 'error',
|
||||
category: 'cross_reference',
|
||||
title: 'DSFA ohne TOM-Massnahmen',
|
||||
description: 'Eine DSFA muss Abhilfemassnahmen enthalten. Ohne TOM-Katalog koennen keine Schutzmassnahmen referenziert werden.',
|
||||
documentType: 'dsfa',
|
||||
crossReferenceType: 'tom',
|
||||
legalReference: 'Art. 35 Abs. 7d DSGVO',
|
||||
suggestion: 'TOM-Massnahmen definieren, bevor die DSFA erstellt wird.',
|
||||
})
|
||||
}
|
||||
|
||||
// Check 7: Datenschutzerklaerung ohne Loeschfristen
|
||||
if (documentType === 'dsi' && validationContext.crossReferences.retentionCategories.length === 0) {
|
||||
findings.push({
|
||||
id: 'DET-DSI-NO-LF',
|
||||
severity: 'warning',
|
||||
category: 'cross_reference',
|
||||
title: 'Datenschutzerklaerung ohne Loeschfristen',
|
||||
description: 'Die Datenschutzerklaerung muss Angaben zur Speicherdauer enthalten. Ohne definierte Loeschfristen fehlt diese Information.',
|
||||
documentType: 'dsi',
|
||||
crossReferenceType: 'lf',
|
||||
legalReference: 'Art. 13 Abs. 2a DSGVO',
|
||||
suggestion: 'Loeschfristen definieren und in der Datenschutzerklaerung referenzieren.',
|
||||
})
|
||||
}
|
||||
|
||||
// Check 8: AVV ohne VVT-Kontext
|
||||
if (documentType === 'av_vertrag' && validationContext.crossReferences.vvtCategories.length === 0) {
|
||||
findings.push({
|
||||
id: 'DET-AV-NO-VVT',
|
||||
severity: 'warning',
|
||||
category: 'cross_reference',
|
||||
title: 'AVV ohne VVT-Kontext',
|
||||
description: 'Ein Auftragsverarbeitungsvertrag sollte auf den im VVT dokumentierten Verarbeitungstaetigkeiten basieren.',
|
||||
documentType: 'av_vertrag',
|
||||
crossReferenceType: 'vvt',
|
||||
legalReference: 'Art. 28 Abs. 3 i.V.m. Art. 30 DSGVO',
|
||||
suggestion: 'VVT erstellen, um die betroffenen Verarbeitungstaetigkeiten fuer den AVV zu identifizieren.',
|
||||
})
|
||||
}
|
||||
|
||||
return findings
|
||||
}
|
||||
|
||||
@@ -127,7 +257,8 @@ export async function POST(request: NextRequest) {
|
||||
{ role: 'user', content: crossCheckPrompt },
|
||||
],
|
||||
stream: false,
|
||||
options: { temperature: 0.1, num_predict: 8192 },
|
||||
think: false,
|
||||
options: { temperature: 0.1, num_predict: 8192, num_ctx: 8192 },
|
||||
format: 'json',
|
||||
}),
|
||||
signal: AbortSignal.timeout(120000),
|
||||
@@ -160,10 +291,18 @@ export async function POST(request: NextRequest) {
|
||||
// LLM unavailable, continue with deterministic results only
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Stufe 1b: Verbotene Formulierungen (Anti-Fake-Evidence)
|
||||
// ---------------------------------------------------------------
|
||||
const forbiddenFindings = checkForbiddenFormulations(
|
||||
draftContent || '',
|
||||
validationContext.evidenceContext,
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Combine results
|
||||
// ---------------------------------------------------------------
|
||||
const allFindings = [...deterministicFindings, ...llmFindings]
|
||||
const allFindings = [...deterministicFindings, ...forbiddenFindings, ...llmFindings]
|
||||
const errors = allFindings.filter(f => f.severity === 'error')
|
||||
const warnings = allFindings.filter(f => f.severity === 'warning')
|
||||
const suggestions = allFindings.filter(f => f.severity === 'suggestion')
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const SDK_URL = process.env.SDK_URL || 'http://ai-compliance-sdk:8090'
|
||||
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const resp = await fetch(`${SDK_URL}/sdk/v1/ai-registration/${id}`)
|
||||
const data = await resp.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: 'Failed to fetch registration' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const tenantId = request.headers.get('x-tenant-id') || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||
const body = await request.json()
|
||||
const resp = await fetch(`${SDK_URL}/sdk/v1/ai-registration/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Tenant-ID': tenantId },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
const data = await resp.json()
|
||||
return NextResponse.json(data, { status: resp.status })
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: 'Failed to update registration' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const body = await request.json()
|
||||
const resp = await fetch(`${SDK_URL}/sdk/v1/ai-registration/${id}/status`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
const data = await resp.json()
|
||||
return NextResponse.json(data, { status: resp.status })
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: 'Failed to update status' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
32
admin-compliance/app/api/sdk/v1/ai-registration/route.ts
Normal file
32
admin-compliance/app/api/sdk/v1/ai-registration/route.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const SDK_URL = process.env.SDK_URL || 'http://ai-compliance-sdk:8090'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const tenantId = request.headers.get('x-tenant-id') || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||
const resp = await fetch(`${SDK_URL}/sdk/v1/ai-registration`, {
|
||||
headers: { 'X-Tenant-ID': tenantId },
|
||||
})
|
||||
const data = await resp.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: 'Failed to fetch registrations' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const tenantId = request.headers.get('x-tenant-id') || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||
const body = await request.json()
|
||||
const resp = await fetch(`${SDK_URL}/sdk/v1/ai-registration`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Tenant-ID': tenantId },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
const data = await resp.json()
|
||||
return NextResponse.json(data, { status: resp.status })
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: 'Failed to create registration' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
108
admin-compliance/app/api/sdk/v1/audit-llm/[[...path]]/route.ts
Normal file
108
admin-compliance/app/api/sdk/v1/audit-llm/[[...path]]/route.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* LLM Audit API Proxy - Catch-all route
|
||||
* Proxies all /api/sdk/v1/audit-llm/* requests to ai-compliance-sdk /sdk/v1/audit/*
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const SDK_BACKEND_URL = process.env.SDK_API_URL || 'http://ai-compliance-sdk:8090'
|
||||
|
||||
async function proxyRequest(
|
||||
request: NextRequest,
|
||||
pathSegments: string[] | undefined,
|
||||
method: string
|
||||
) {
|
||||
const pathStr = pathSegments?.join('/') || ''
|
||||
const searchParams = request.nextUrl.searchParams.toString()
|
||||
const basePath = `${SDK_BACKEND_URL}/sdk/v1/audit`
|
||||
const url = pathStr
|
||||
? `${basePath}/${pathStr}${searchParams ? `?${searchParams}` : ''}`
|
||||
: `${basePath}${searchParams ? `?${searchParams}` : ''}`
|
||||
|
||||
try {
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
const headerNames = ['authorization', 'x-namespace-id', 'x-tenant-slug']
|
||||
for (const name of headerNames) {
|
||||
const value = request.headers.get(name)
|
||||
if (value) {
|
||||
headers[name] = value
|
||||
}
|
||||
}
|
||||
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
const clientUserId = request.headers.get('x-user-id')
|
||||
const clientTenantId = request.headers.get('x-tenant-id')
|
||||
headers['X-User-ID'] = (clientUserId && uuidRegex.test(clientUserId)) ? clientUserId : '00000000-0000-0000-0000-000000000001'
|
||||
headers['X-Tenant-ID'] = (clientTenantId && uuidRegex.test(clientTenantId)) ? clientTenantId : (process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e')
|
||||
|
||||
const fetchOptions: RequestInit = {
|
||||
method,
|
||||
headers,
|
||||
signal: AbortSignal.timeout(60000),
|
||||
}
|
||||
|
||||
if (method === 'POST' || method === 'PUT' || method === 'PATCH') {
|
||||
const body = await request.text()
|
||||
if (body) {
|
||||
fetchOptions.body = body
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(url, fetchOptions)
|
||||
|
||||
// Handle export endpoints that may return CSV
|
||||
const contentType = response.headers.get('content-type') || ''
|
||||
if (contentType.includes('text/csv') || contentType.includes('application/octet-stream')) {
|
||||
const blob = await response.arrayBuffer()
|
||||
return new NextResponse(blob, {
|
||||
status: response.status,
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Content-Disposition': response.headers.get('content-disposition') || 'attachment',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
let errorJson
|
||||
try {
|
||||
errorJson = JSON.parse(errorText)
|
||||
} catch {
|
||||
errorJson = { error: errorText }
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: `Backend Error: ${response.status}`, ...errorJson },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (error) {
|
||||
console.error('LLM Audit API proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Verbindung zum SDK Backend fehlgeschlagen' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'GET')
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'POST')
|
||||
}
|
||||
349
admin-compliance/app/api/sdk/v1/canonical/route.ts
Normal file
349
admin-compliance/app/api/sdk/v1/canonical/route.ts
Normal file
@@ -0,0 +1,349 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
/**
|
||||
* Proxy: GET /api/sdk/v1/canonical?endpoint=...
|
||||
*
|
||||
* Routes to backend canonical control endpoints:
|
||||
* endpoint=frameworks → GET /api/compliance/v1/canonical/frameworks
|
||||
* endpoint=controls → GET /api/compliance/v1/canonical/controls(?severity=...&domain=...)
|
||||
* endpoint=control&id= → GET /api/compliance/v1/canonical/controls/{id}
|
||||
* endpoint=sources → GET /api/compliance/v1/canonical/sources
|
||||
* endpoint=licenses → GET /api/compliance/v1/canonical/licenses
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const endpoint = searchParams.get('endpoint') || 'frameworks'
|
||||
|
||||
let backendPath: string
|
||||
|
||||
switch (endpoint) {
|
||||
case 'frameworks':
|
||||
backendPath = '/api/compliance/v1/canonical/frameworks'
|
||||
break
|
||||
|
||||
case 'controls': {
|
||||
const controlParams = new URLSearchParams()
|
||||
const passthrough = ['severity', 'domain', 'release_state', 'verification_method', 'category', 'evidence_type',
|
||||
'target_audience', 'source', 'search', 'control_type', 'exclude_duplicates', 'sort', 'order', 'limit', 'offset']
|
||||
for (const key of passthrough) {
|
||||
const val = searchParams.get(key)
|
||||
if (val) controlParams.set(key, val)
|
||||
}
|
||||
const qs = controlParams.toString()
|
||||
backendPath = `/api/compliance/v1/canonical/controls${qs ? `?${qs}` : ''}`
|
||||
break
|
||||
}
|
||||
|
||||
case 'controls-count': {
|
||||
const countParams = new URLSearchParams()
|
||||
const countPassthrough = ['severity', 'domain', 'release_state', 'verification_method', 'category', 'evidence_type',
|
||||
'target_audience', 'source', 'search', 'control_type', 'exclude_duplicates']
|
||||
for (const key of countPassthrough) {
|
||||
const val = searchParams.get(key)
|
||||
if (val) countParams.set(key, val)
|
||||
}
|
||||
const countQs = countParams.toString()
|
||||
backendPath = `/api/compliance/v1/canonical/controls-count${countQs ? `?${countQs}` : ''}`
|
||||
break
|
||||
}
|
||||
|
||||
case 'controls-meta': {
|
||||
const metaParams = new URLSearchParams()
|
||||
const metaPassthrough = ['severity', 'domain', 'release_state', 'verification_method', 'category', 'evidence_type',
|
||||
'target_audience', 'source', 'search', 'control_type', 'exclude_duplicates']
|
||||
for (const key of metaPassthrough) {
|
||||
const val = searchParams.get(key)
|
||||
if (val) metaParams.set(key, val)
|
||||
}
|
||||
const metaQs = metaParams.toString()
|
||||
backendPath = `/api/compliance/v1/canonical/controls-meta${metaQs ? `?${metaQs}` : ''}`
|
||||
break
|
||||
}
|
||||
|
||||
case 'control': {
|
||||
const controlId = searchParams.get('id')
|
||||
if (!controlId) {
|
||||
return NextResponse.json({ error: 'Missing control id' }, { status: 400 })
|
||||
}
|
||||
backendPath = `/api/compliance/v1/canonical/controls/${encodeURIComponent(controlId)}`
|
||||
break
|
||||
}
|
||||
|
||||
case 'sources':
|
||||
backendPath = '/api/compliance/v1/canonical/sources'
|
||||
break
|
||||
|
||||
case 'licenses':
|
||||
backendPath = '/api/compliance/v1/canonical/licenses'
|
||||
break
|
||||
|
||||
// Generator endpoints
|
||||
case 'generate-jobs':
|
||||
backendPath = '/api/compliance/v1/canonical/generate/jobs'
|
||||
break
|
||||
|
||||
case 'generate-status': {
|
||||
const jobId = searchParams.get('jobId')
|
||||
if (!jobId) {
|
||||
return NextResponse.json({ error: 'Missing jobId' }, { status: 400 })
|
||||
}
|
||||
backendPath = `/api/compliance/v1/canonical/generate/status/${encodeURIComponent(jobId)}`
|
||||
break
|
||||
}
|
||||
|
||||
case 'review-queue': {
|
||||
const state = searchParams.get('release_state') || 'needs_review'
|
||||
backendPath = `/api/compliance/v1/canonical/generate/review-queue?release_state=${encodeURIComponent(state)}`
|
||||
break
|
||||
}
|
||||
|
||||
case 'processed-stats':
|
||||
backendPath = '/api/compliance/v1/canonical/generate/processed-stats'
|
||||
break
|
||||
|
||||
case 'categories':
|
||||
backendPath = '/api/compliance/v1/canonical/categories'
|
||||
break
|
||||
|
||||
case 'traceability': {
|
||||
const traceId = searchParams.get('id')
|
||||
if (!traceId) {
|
||||
return NextResponse.json({ error: 'Missing control id' }, { status: 400 })
|
||||
}
|
||||
backendPath = `/api/compliance/v1/canonical/controls/${encodeURIComponent(traceId)}/traceability`
|
||||
break
|
||||
}
|
||||
|
||||
case 'provenance': {
|
||||
const provId = searchParams.get('id')
|
||||
if (!provId) {
|
||||
return NextResponse.json({ error: 'Missing control id' }, { status: 400 })
|
||||
}
|
||||
backendPath = `/api/compliance/v1/canonical/controls/${encodeURIComponent(provId)}/provenance`
|
||||
break
|
||||
}
|
||||
|
||||
case 'atomic-stats':
|
||||
backendPath = '/api/compliance/v1/canonical/controls/atomic-stats'
|
||||
break
|
||||
|
||||
case 'similar': {
|
||||
const simControlId = searchParams.get('id')
|
||||
if (!simControlId) {
|
||||
return NextResponse.json({ error: 'Missing control id' }, { status: 400 })
|
||||
}
|
||||
const simThreshold = searchParams.get('threshold') || '0.85'
|
||||
backendPath = `/api/compliance/v1/canonical/controls/${encodeURIComponent(simControlId)}/similar?threshold=${simThreshold}`
|
||||
break
|
||||
}
|
||||
|
||||
case 'blocked-sources':
|
||||
backendPath = '/api/compliance/v1/canonical/blocked-sources'
|
||||
break
|
||||
|
||||
case 'v1-matches': {
|
||||
const matchId = searchParams.get('id')
|
||||
if (!matchId) {
|
||||
return NextResponse.json({ error: 'Missing control id' }, { status: 400 })
|
||||
}
|
||||
backendPath = `/api/compliance/v1/canonical/controls/${encodeURIComponent(matchId)}/v1-matches`
|
||||
break
|
||||
}
|
||||
|
||||
case 'v1-enrichment-stats':
|
||||
backendPath = '/api/compliance/v1/canonical/controls/v1-enrichment-stats'
|
||||
break
|
||||
|
||||
case 'obligation-dedup-stats':
|
||||
backendPath = '/api/compliance/v1/canonical/obligations/dedup-stats'
|
||||
break
|
||||
|
||||
case 'controls-customer': {
|
||||
const custSeverity = searchParams.get('severity')
|
||||
const custDomain = searchParams.get('domain')
|
||||
const custParams = new URLSearchParams()
|
||||
if (custSeverity) custParams.set('severity', custSeverity)
|
||||
if (custDomain) custParams.set('domain', custDomain)
|
||||
const custQs = custParams.toString()
|
||||
backendPath = `/api/compliance/v1/canonical/controls-customer${custQs ? `?${custQs}` : ''}`
|
||||
break
|
||||
}
|
||||
|
||||
default:
|
||||
return NextResponse.json({ error: `Unknown endpoint: ${endpoint}` }, { status: 400 })
|
||||
}
|
||||
|
||||
const response = await fetch(`${BACKEND_URL}${backendPath}`)
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
return NextResponse.json(null, { status: 404 })
|
||||
}
|
||||
const errorText = await response.text()
|
||||
return NextResponse.json(
|
||||
{ error: 'Backend error', details: errorText },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(await response.json())
|
||||
} catch (error) {
|
||||
console.error('Canonical control proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to backend' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy: POST /api/sdk/v1/canonical?endpoint=...
|
||||
*
|
||||
* endpoint=create-control → POST /api/compliance/v1/canonical/controls
|
||||
* endpoint=similarity-check&id= → POST /api/compliance/v1/canonical/controls/{id}/similarity-check
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const endpoint = searchParams.get('endpoint')
|
||||
const body = await request.json()
|
||||
|
||||
let backendPath: string
|
||||
|
||||
if (endpoint === 'create-control') {
|
||||
backendPath = '/api/compliance/v1/canonical/controls'
|
||||
} else if (endpoint === 'generate') {
|
||||
backendPath = '/api/compliance/v1/canonical/generate'
|
||||
} else if (endpoint === 'review') {
|
||||
const controlId = searchParams.get('id')
|
||||
if (!controlId) {
|
||||
return NextResponse.json({ error: 'Missing control id' }, { status: 400 })
|
||||
}
|
||||
backendPath = `/api/compliance/v1/canonical/generate/review/${encodeURIComponent(controlId)}`
|
||||
} else if (endpoint === 'bulk-review') {
|
||||
backendPath = '/api/compliance/v1/canonical/generate/bulk-review'
|
||||
} else if (endpoint === 'blocked-sources-cleanup') {
|
||||
backendPath = '/api/compliance/v1/canonical/blocked-sources/cleanup'
|
||||
} else if (endpoint === 'enrich-v1-matches') {
|
||||
const dryRun = searchParams.get('dry_run') ?? 'true'
|
||||
const batchSize = searchParams.get('batch_size') ?? '100'
|
||||
const enrichOffset = searchParams.get('offset') ?? '0'
|
||||
backendPath = `/api/compliance/v1/canonical/controls/enrich-v1-matches?dry_run=${dryRun}&batch_size=${batchSize}&offset=${enrichOffset}`
|
||||
} else if (endpoint === 'obligation-dedup') {
|
||||
const dryRun = searchParams.get('dry_run') ?? 'true'
|
||||
const batchSize = searchParams.get('batch_size') ?? '0'
|
||||
const dedupOffset = searchParams.get('offset') ?? '0'
|
||||
backendPath = `/api/compliance/v1/canonical/obligations/dedup?dry_run=${dryRun}&batch_size=${batchSize}&offset=${dedupOffset}`
|
||||
} else if (endpoint === 'similarity-check') {
|
||||
const controlId = searchParams.get('id')
|
||||
if (!controlId) {
|
||||
return NextResponse.json({ error: 'Missing control id' }, { status: 400 })
|
||||
}
|
||||
backendPath = `/api/compliance/v1/canonical/controls/${encodeURIComponent(controlId)}/similarity-check`
|
||||
} else {
|
||||
return NextResponse.json({ error: `Unknown POST endpoint: ${endpoint}` }, { status: 400 })
|
||||
}
|
||||
|
||||
const response = await fetch(`${BACKEND_URL}${backendPath}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
return NextResponse.json(
|
||||
{ error: 'Backend error', details: errorText },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(await response.json(), { status: response.status })
|
||||
} catch (error) {
|
||||
console.error('Canonical control POST proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to backend' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy: PUT /api/sdk/v1/canonical?endpoint=update-control&id=AUTH-001
|
||||
*
|
||||
* Routes to: PUT /api/compliance/v1/canonical/controls/{id}
|
||||
*/
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const controlId = searchParams.get('id')
|
||||
if (!controlId) {
|
||||
return NextResponse.json({ error: 'Missing control id' }, { status: 400 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const response = await fetch(
|
||||
`${BACKEND_URL}/api/compliance/v1/canonical/controls/${encodeURIComponent(controlId)}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
return NextResponse.json(
|
||||
{ error: 'Backend error', details: errorText },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(await response.json())
|
||||
} catch (error) {
|
||||
console.error('Canonical control PUT proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to backend' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy: DELETE /api/sdk/v1/canonical?id=AUTH-001
|
||||
*
|
||||
* Routes to: DELETE /api/compliance/v1/canonical/controls/{id}
|
||||
*/
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const controlId = searchParams.get('id')
|
||||
if (!controlId) {
|
||||
return NextResponse.json({ error: 'Missing control id' }, { status: 400 })
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${BACKEND_URL}/api/compliance/v1/canonical/controls/${encodeURIComponent(controlId)}`,
|
||||
{ method: 'DELETE' }
|
||||
)
|
||||
|
||||
if (!response.ok && response.status !== 204) {
|
||||
const errorText = await response.text()
|
||||
return NextResponse.json(
|
||||
{ error: 'Backend error', details: errorText },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
return new NextResponse(null, { status: 204 })
|
||||
} catch (error) {
|
||||
console.error('Canonical control DELETE proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to backend' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
166
admin-compliance/app/api/sdk/v1/company-profile/route.ts
Normal file
166
admin-compliance/app/api/sdk/v1/company-profile/route.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
function getIds(request: NextRequest, body?: Record<string, unknown>) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const tenantId = searchParams.get('tenant_id') || 'default'
|
||||
const projectId = searchParams.get('project_id') || (body?.project_id as string) || ''
|
||||
const qs = projectId
|
||||
? `tenant_id=${encodeURIComponent(tenantId)}&project_id=${encodeURIComponent(projectId)}`
|
||||
: `tenant_id=${encodeURIComponent(tenantId)}`
|
||||
return { tenantId, projectId, qs }
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy: GET /api/sdk/v1/company-profile → Backend GET /api/v1/company-profile
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { tenantId, qs } = getIds(request)
|
||||
|
||||
const response = await fetch(
|
||||
`${BACKEND_URL}/api/v1/company-profile?${qs}`,
|
||||
{
|
||||
headers: {
|
||||
'X-Tenant-ID': tenantId,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
return NextResponse.json(null, { status: 404 })
|
||||
}
|
||||
const errorText = await response.text()
|
||||
return NextResponse.json(
|
||||
{ error: 'Backend error', details: errorText },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch company profile:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to backend' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy: POST /api/sdk/v1/company-profile → Backend POST /api/v1/company-profile
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { tenantId, qs } = getIds(request, body)
|
||||
|
||||
const response = await fetch(
|
||||
`${BACKEND_URL}/api/v1/company-profile?${qs}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Tenant-ID': tenantId,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
return NextResponse.json(
|
||||
{ error: 'Backend error', details: errorText },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (error) {
|
||||
console.error('Failed to save company profile:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to backend' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy: DELETE /api/sdk/v1/company-profile → Backend DELETE /api/v1/company-profile
|
||||
* DSGVO Art. 17 Recht auf Löschung
|
||||
*/
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const { tenantId, qs } = getIds(request)
|
||||
|
||||
const response = await fetch(
|
||||
`${BACKEND_URL}/api/v1/company-profile?${qs}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-Tenant-ID': tenantId,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
return NextResponse.json(
|
||||
{ error: 'Backend error', details: errorText },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(await response.json())
|
||||
} catch (error) {
|
||||
console.error('Failed to delete company profile:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to backend' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy: PATCH /api/sdk/v1/company-profile → Backend PATCH /api/v1/company-profile
|
||||
* Partial updates for individual fields
|
||||
*/
|
||||
export async function PATCH(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { tenantId, qs } = getIds(request, body)
|
||||
|
||||
const response = await fetch(
|
||||
`${BACKEND_URL}/api/v1/company-profile?${qs}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Tenant-ID': tenantId,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
return NextResponse.json(
|
||||
{ error: 'Backend error', details: errorText },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (error) {
|
||||
console.error('Failed to patch company profile:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to backend' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
84
admin-compliance/app/api/sdk/v1/compliance-scope/route.ts
Normal file
84
admin-compliance/app/api/sdk/v1/compliance-scope/route.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
/**
|
||||
* Proxy: GET /api/sdk/v1/compliance-scope → Backend GET /api/v1/compliance-scope
|
||||
* Retrieves the persisted scope decision for a tenant.
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const tenantId = searchParams.get('tenant_id') || 'default'
|
||||
|
||||
const response = await fetch(
|
||||
`${BACKEND_URL}/api/compliance/v1/compliance-scope?tenant_id=${encodeURIComponent(tenantId)}`,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Tenant-ID': tenantId,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
return NextResponse.json(null, { status: 404 })
|
||||
}
|
||||
const errorText = await response.text()
|
||||
return NextResponse.json(
|
||||
{ error: 'Backend error', details: errorText },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch compliance scope:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to backend' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy: POST /api/sdk/v1/compliance-scope → Backend POST /api/v1/compliance-scope
|
||||
* Persists the scope decision and answers.
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const tenantId = body.tenant_id || 'default'
|
||||
|
||||
const response = await fetch(
|
||||
`${BACKEND_URL}/api/v1/compliance-scope?tenant_id=${encodeURIComponent(tenantId)}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Tenant-ID': tenantId,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
return NextResponse.json(
|
||||
{ error: 'Backend error', details: errorText },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (error) {
|
||||
console.error('Failed to save compliance scope:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to backend' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
135
admin-compliance/app/api/sdk/v1/compliance/[[...path]]/route.ts
Normal file
135
admin-compliance/app/api/sdk/v1/compliance/[[...path]]/route.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Compliance API Proxy - Catch-all route
|
||||
* Proxies all /api/sdk/v1/compliance/* requests to backend-compliance
|
||||
*
|
||||
* Backend routes: requirements, controls, evidence, risks, audit, ai
|
||||
* All under /api/compliance/ prefix on backend-compliance:8002
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
async function proxyRequest(
|
||||
request: NextRequest,
|
||||
pathSegments: string[] | undefined,
|
||||
method: string
|
||||
) {
|
||||
const pathStr = pathSegments?.join('/') || ''
|
||||
const searchParams = request.nextUrl.searchParams.toString()
|
||||
const basePath = `${BACKEND_URL}/api/compliance`
|
||||
const url = pathStr
|
||||
? `${basePath}/${pathStr}${searchParams ? `?${searchParams}` : ''}`
|
||||
: `${basePath}${searchParams ? `?${searchParams}` : ''}`
|
||||
|
||||
try {
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
const headerNames = ['authorization', 'x-namespace-id', 'x-tenant-slug']
|
||||
for (const name of headerNames) {
|
||||
const value = request.headers.get(name)
|
||||
if (value) {
|
||||
headers[name] = value
|
||||
}
|
||||
}
|
||||
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
const clientUserId = request.headers.get('x-user-id')
|
||||
const clientTenantId = request.headers.get('x-tenant-id')
|
||||
headers['X-User-ID'] = (clientUserId && uuidRegex.test(clientUserId)) ? clientUserId : '00000000-0000-0000-0000-000000000001'
|
||||
headers['X-Tenant-ID'] = (clientTenantId && uuidRegex.test(clientTenantId)) ? clientTenantId : (process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e')
|
||||
|
||||
const fetchOptions: RequestInit = {
|
||||
method,
|
||||
headers,
|
||||
signal: AbortSignal.timeout(60000),
|
||||
}
|
||||
|
||||
if (method === 'POST' || method === 'PUT' || method === 'PATCH') {
|
||||
const body = await request.text()
|
||||
if (body) {
|
||||
fetchOptions.body = body
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(url, fetchOptions)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
let errorJson
|
||||
try {
|
||||
errorJson = JSON.parse(errorText)
|
||||
} catch {
|
||||
errorJson = { error: errorText }
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: `Backend Error: ${response.status}`, ...errorJson },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
// Handle PDF/binary responses
|
||||
const contentType = response.headers.get('content-type') || ''
|
||||
if (contentType.includes('application/pdf') || contentType.includes('application/octet-stream')) {
|
||||
const blob = await response.blob()
|
||||
return new NextResponse(blob, {
|
||||
status: response.status,
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Content-Disposition': response.headers.get('content-disposition') || '',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (error) {
|
||||
console.error('Compliance API proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Verbindung zum Compliance Backend fehlgeschlagen' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'GET')
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'POST')
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'PUT')
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'PATCH')
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'DELETE')
|
||||
}
|
||||
@@ -1,39 +1,52 @@
|
||||
/**
|
||||
* DSGVO API Proxy - Catch-all route
|
||||
* Proxies all /api/sdk/v1/dsgvo/* requests to ai-compliance-sdk backend
|
||||
* Evidence Checks API Proxy - Catch-all route
|
||||
* Proxies all /api/sdk/v1/compliance/evidence-checks/* requests to backend-compliance
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const SDK_BACKEND_URL = process.env.SDK_API_URL || 'http://ai-compliance-sdk:8090'
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
async function proxyRequest(
|
||||
request: NextRequest,
|
||||
pathSegments: string[],
|
||||
pathSegments: string[] | undefined,
|
||||
method: string
|
||||
) {
|
||||
const pathStr = pathSegments.join('/')
|
||||
const pathStr = pathSegments?.join('/') || ''
|
||||
const searchParams = request.nextUrl.searchParams.toString()
|
||||
const url = `${SDK_BACKEND_URL}/sdk/v1/dsgvo/${pathStr}${searchParams ? `?${searchParams}` : ''}`
|
||||
const basePath = `${BACKEND_URL}/api/compliance/evidence-checks`
|
||||
const url = pathStr
|
||||
? `${basePath}/${pathStr}${searchParams ? `?${searchParams}` : ''}`
|
||||
: `${basePath}${searchParams ? `?${searchParams}` : ''}`
|
||||
|
||||
try {
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Tenant-Id': '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e',
|
||||
'X-User-Id': 'admin',
|
||||
}
|
||||
|
||||
// Forward auth headers if present
|
||||
const authHeader = request.headers.get('authorization')
|
||||
if (authHeader) {
|
||||
headers['Authorization'] = authHeader
|
||||
}
|
||||
|
||||
const tenantHeader = request.headers.get('x-tenant-id')
|
||||
if (tenantHeader) {
|
||||
headers['X-Tenant-Id'] = tenantHeader
|
||||
}
|
||||
|
||||
const userIdHeader = request.headers.get('x-user-id')
|
||||
if (userIdHeader) {
|
||||
headers['X-User-Id'] = userIdHeader
|
||||
}
|
||||
|
||||
const fetchOptions: RequestInit = {
|
||||
method,
|
||||
headers,
|
||||
signal: AbortSignal.timeout(30000),
|
||||
}
|
||||
|
||||
// Add body for POST/PUT/PATCH methods
|
||||
if (['POST', 'PUT', 'PATCH'].includes(method)) {
|
||||
const contentType = request.headers.get('content-type')
|
||||
if (contentType?.includes('application/json')) {
|
||||
@@ -43,27 +56,13 @@ async function proxyRequest(
|
||||
fetchOptions.body = text
|
||||
}
|
||||
} catch {
|
||||
// Empty or invalid body - continue without
|
||||
// Empty or invalid body
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(url, fetchOptions)
|
||||
|
||||
// Handle non-JSON responses (e.g., PDF export)
|
||||
const responseContentType = response.headers.get('content-type')
|
||||
if (responseContentType?.includes('application/pdf') ||
|
||||
responseContentType?.includes('application/octet-stream')) {
|
||||
const blob = await response.blob()
|
||||
return new NextResponse(blob, {
|
||||
status: response.status,
|
||||
headers: {
|
||||
'Content-Type': responseContentType,
|
||||
'Content-Disposition': response.headers.get('content-disposition') || '',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
let errorJson
|
||||
@@ -81,9 +80,9 @@ async function proxyRequest(
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (error) {
|
||||
console.error('DSGVO API proxy error:', error)
|
||||
console.error('Evidence Checks API proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Verbindung zum SDK Backend fehlgeschlagen' },
|
||||
{ error: 'Verbindung zum Backend fehlgeschlagen' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
@@ -91,7 +90,7 @@ async function proxyRequest(
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path: string[] }> }
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'GET')
|
||||
@@ -99,7 +98,7 @@ export async function GET(
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path: string[] }> }
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'POST')
|
||||
@@ -107,7 +106,7 @@ export async function POST(
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path: string[] }> }
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'PUT')
|
||||
@@ -115,7 +114,7 @@ export async function PUT(
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path: string[] }> }
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'PATCH')
|
||||
@@ -123,7 +122,7 @@ export async function PATCH(
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path: string[] }> }
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'DELETE')
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Process Tasks API Proxy - Catch-all route
|
||||
* Proxies all /api/sdk/v1/compliance/process-tasks/* requests to backend-compliance
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
async function proxyRequest(
|
||||
request: NextRequest,
|
||||
pathSegments: string[] | undefined,
|
||||
method: string
|
||||
) {
|
||||
const pathStr = pathSegments?.join('/') || ''
|
||||
const searchParams = request.nextUrl.searchParams.toString()
|
||||
const basePath = `${BACKEND_URL}/api/compliance/process-tasks`
|
||||
const url = pathStr
|
||||
? `${basePath}/${pathStr}${searchParams ? `?${searchParams}` : ''}`
|
||||
: `${basePath}${searchParams ? `?${searchParams}` : ''}`
|
||||
|
||||
try {
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Tenant-Id': '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e',
|
||||
'X-User-Id': 'admin',
|
||||
}
|
||||
|
||||
const authHeader = request.headers.get('authorization')
|
||||
if (authHeader) {
|
||||
headers['Authorization'] = authHeader
|
||||
}
|
||||
|
||||
const tenantHeader = request.headers.get('x-tenant-id')
|
||||
if (tenantHeader) {
|
||||
headers['X-Tenant-Id'] = tenantHeader
|
||||
}
|
||||
|
||||
const userIdHeader = request.headers.get('x-user-id')
|
||||
if (userIdHeader) {
|
||||
headers['X-User-Id'] = userIdHeader
|
||||
}
|
||||
|
||||
const fetchOptions: RequestInit = {
|
||||
method,
|
||||
headers,
|
||||
signal: AbortSignal.timeout(30000),
|
||||
}
|
||||
|
||||
if (['POST', 'PUT', 'PATCH'].includes(method)) {
|
||||
const contentType = request.headers.get('content-type')
|
||||
if (contentType?.includes('application/json')) {
|
||||
try {
|
||||
const text = await request.text()
|
||||
if (text && text.trim()) {
|
||||
fetchOptions.body = text
|
||||
}
|
||||
} catch {
|
||||
// Empty or invalid body
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(url, fetchOptions)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
let errorJson
|
||||
try {
|
||||
errorJson = JSON.parse(errorText)
|
||||
} catch {
|
||||
errorJson = { error: errorText }
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: `Backend Error: ${response.status}`, ...errorJson },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (error) {
|
||||
console.error('Process Tasks API proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Verbindung zum Backend fehlgeschlagen' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'GET')
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'POST')
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'PUT')
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'PATCH')
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'DELETE')
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user