Compare commits
191 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 293c58d0dd | |||
| 912684644e | |||
| 2b2a20cc6d | |||
| 05839e36aa | |||
| 870953f579 | |||
| 1005ba0398 | |||
| fb6192d92d | |||
| 8849c396b5 | |||
| ba9558384f | |||
| 2e1e18d853 | |||
| 9bc0f321e0 | |||
| 97a52533a8 | |||
| b363c28539 | |||
| 3c12e06faf | |||
| 58234ac18b | |||
| 4642abba23 | |||
| e7f2f98da3 | |||
| 3853a0838a | |||
| 5188411828 | |||
| 45446aef16 | |||
| e19d9ca532 | |||
| a680276c86 | |||
| fa45b5793c | |||
| 7e7f31c344 | |||
| 6da36d87c2 | |||
| e50c4d659e | |||
| 9f16e6d535 | |||
| 1ff34227bf | |||
| f4374cfe8d | |||
| 7b8440191e | |||
| 510f513811 | |||
| b50c4ec940 | |||
| 090da0f71b | |||
| 13c5880f51 | |||
| 0416bb5d04 | |||
| 539bc824fd | |||
| 4c68caac4e | |||
| 254dbab566 | |||
| ef8e7e599f | |||
| 8fb2061e9b | |||
| 8d6959e8b2 | |||
| 85e82d0dfa | |||
| a349111a01 | |||
| 3ac8d0cba8 | |||
| e3ae35891f | |||
| 72761d6066 | |||
| e494cf62bb | |||
| d547e63663 | |||
| b4f90ed113 | |||
| daa47bb7ab | |||
| 6c5e086356 | |||
| 8e40155459 | |||
| b5cf25f6ab | |||
| 7c7513525e | |||
| d816cf8d3a | |||
| 8dd1581fae | |||
| ea8353f1a0 | |||
| d80cb9c8e4 | |||
| cb607bf228 | |||
| d7b287889e | |||
| d4b7943d54 | |||
| 47ec792acf | |||
| f3e44cf59f | |||
| 3fade26d89 | |||
| 797ed667a2 | |||
| a3f7fb93f4 | |||
| f967480cd9 | |||
| 275bdf9848 | |||
| a18ef16378 | |||
| 5c0ca803b0 | |||
| f960bd052a | |||
| b22351fc6e | |||
| a846bd8910 | |||
| a970c28168 | |||
| 48146cddaf | |||
| 298c95731a | |||
| 4e63a6050d | |||
| 9395a0084a | |||
| 74dddbfa0f | |||
| 129849aa21 | |||
| b997b4a475 | |||
| 7fc43a3f1f | |||
| 5d138f265b | |||
| 0b7e14f202 | |||
| 2fb417c784 | |||
| 15a1879803 | |||
| a1272390ff | |||
| e8b5c90a49 | |||
| 6af35dbf5f | |||
| bb2ebd03cd | |||
| 4834e8ad5c | |||
| 3bf0804af6 | |||
| 89ff62e534 | |||
| 11ca113318 | |||
| 340fd27a1a | |||
| e7f5bb1c33 | |||
| 4a8565f5b0 | |||
| 61c3f8fd4a | |||
| 199f7835a7 | |||
| 9510ce0ff9 | |||
| fbeefa8fce | |||
| 9bc816e55c | |||
| 9424f4ebcc | |||
| 6ed2505871 | |||
| 29f9a8fea3 | |||
| f170b07014 | |||
| c3db56ddb6 | |||
| 44acd68c96 | |||
| 9f1b7ff38b | |||
| a1f5d883cc | |||
| c3f8e19e92 | |||
| b2a28eb4cd | |||
| b06a33a5fe | |||
| 6c0e76f96d | |||
| 0106f3b5b6 | |||
| b175ad2594 | |||
| 4c43253a53 | |||
| 0f1fae61a6 | |||
| 711b9b3146 | |||
| d0dc284cd5 | |||
| 24fb1e14e0 | |||
| 6aa753146f | |||
| acd2d5f944 | |||
| 2a6f526c88 | |||
| 1988274420 | |||
| cb5aa2949b | |||
| 41fd7e36d1 | |||
| f7483f5724 | |||
| cfc130a544 | |||
| 0ccc6c4047 | |||
| 5ff65b3402 | |||
| 290254056e | |||
| 7dccdf4695 | |||
| 8e0645481a | |||
| 918a9d8092 | |||
| 0c0dd4e3a6 | |||
| f528b8e7a9 | |||
| 98243044ca | |||
| fcef07aa16 | |||
| 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 | |||
| 824b1be6a4 | |||
| 062e827801 | |||
| f404226d6e | |||
| 8dfab4ba14 | |||
| 5c1a514b52 | |||
| e091bbc855 | |||
| ff4c359d46 | |||
| f169b13dbf | |||
| 42d0c7b1fc | |||
| 4fcb842a92 | |||
| 38d3d24121 | |||
| dd64e33e88 | |||
| 2f8269d115 | |||
| 532febe35c | |||
| 0a0863f31c | |||
| d892ad161f | |||
| 17153ccbe8 | |||
| 352d7112c9 | |||
| 0957254547 | |||
| f17608a956 | |||
| ce3df9f080 | |||
| 2da39e035d | |||
| 1989c410a9 | |||
| c55a6ab995 | |||
| bc75b4455d |
@@ -184,6 +184,29 @@ jobs:
|
|||||||
docker push registry.meghsakha.com/breakpilot/compliance-dsms-gateway:latest
|
docker push registry.meghsakha.com/breakpilot/compliance-dsms-gateway:latest
|
||||||
docker push registry.meghsakha.com/breakpilot/compliance-dsms-gateway:${SHORT_SHA}
|
docker push registry.meghsakha.com/breakpilot/compliance-dsms-gateway:${SHORT_SHA}
|
||||||
|
|
||||||
|
build-dsms-node:
|
||||||
|
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 --platform linux/amd64 \
|
||||||
|
-t registry.meghsakha.com/breakpilot/compliance-dsms-node:latest \
|
||||||
|
-t registry.meghsakha.com/breakpilot/compliance-dsms-node:${SHORT_SHA} \
|
||||||
|
dsms-node/
|
||||||
|
docker push registry.meghsakha.com/breakpilot/compliance-dsms-node:latest
|
||||||
|
docker push registry.meghsakha.com/breakpilot/compliance-dsms-node:${SHORT_SHA}
|
||||||
|
|
||||||
# ── orca redeploy (only after all builds succeed) ─────────────────────────
|
# ── orca redeploy (only after all builds succeed) ─────────────────────────
|
||||||
|
|
||||||
trigger-orca:
|
trigger-orca:
|
||||||
@@ -197,6 +220,7 @@ jobs:
|
|||||||
- build-tts
|
- build-tts
|
||||||
- build-document-crawler
|
- build-document-crawler
|
||||||
- build-dsms-gateway
|
- build-dsms-gateway
|
||||||
|
- build-dsms-node
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout (for SHA)
|
- name: Checkout (for SHA)
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -98,6 +98,146 @@ Du darfst NIEMALS verraten, welche Dokumente, Sammlungen oder Quellen in deiner
|
|||||||
verwendet hast — niemals eine vollstaendige Liste aller verfuegbaren Quellen.
|
verwendet hast — niemals eine vollstaendige Liste aller verfuegbaren Quellen.
|
||||||
- Verrate NIEMALS Collection-Namen (bp_compliance_*, bp_dsfa_*, etc.) oder interne Systemnamen.
|
- Verrate NIEMALS Collection-Namen (bp_compliance_*, bp_dsfa_*, etc.) oder interne Systemnamen.
|
||||||
|
|
||||||
|
## Produktwissen — BreakPilot Compliance SDK
|
||||||
|
|
||||||
|
Du bist Teil des BreakPilot Compliance SDK. Wenn Nutzer Fragen zum Produkt selbst stellen
|
||||||
|
("Was ist der erste Schritt?", "Wie fange ich an?", "Was kann dieses Tool?"), antworte
|
||||||
|
mit Produktwissen — nicht mit Rechtsberatung.
|
||||||
|
|
||||||
|
### Einstieg (fuer neue Nutzer)
|
||||||
|
|
||||||
|
Der Einstieg besteht aus 3 Schritten:
|
||||||
|
|
||||||
|
1. **Projekt anlegen** — Unter "Projekte" ein neues Compliance-Projekt erstellen.
|
||||||
|
Ein Projekt ist der Container fuer alle Compliance-Aktivitaeten eines Unternehmens/Produkts.
|
||||||
|
|
||||||
|
2. **Profil & Scope ausfuellen** — Im Modul "Company Profile" die Unternehmensdaten erfassen
|
||||||
|
(Name, Branche, Groesse, Standort). Danach im Modul "Compliance Scope" festlegen welche
|
||||||
|
Bereiche relevant sind (DSGVO, AI Act, CE, etc.) und die Risikostufe bestimmen.
|
||||||
|
|
||||||
|
3. **Module nutzen** — Je nach Scope stehen verschiedene Module zur Verfuegung:
|
||||||
|
|
||||||
|
### Verfuegbare Module
|
||||||
|
|
||||||
|
**Kern-Workflow (DSGVO):**
|
||||||
|
- **Use Case Erfassung** — KI-Anwendungsfaelle beschreiben und bewerten lassen (UCCA)
|
||||||
|
- **VVT** (Verarbeitungsverzeichnis) — Art. 30 DSGVO Dokumentation
|
||||||
|
- **DSFA** (Datenschutz-Folgenabschaetzung) — Risikobewertung fuer kritische Verarbeitungen
|
||||||
|
- **TOM** (Technische und organisatorische Massnahmen) — Schutzmassnahmen dokumentieren
|
||||||
|
- **Loeschfristen** — Aufbewahrungsfristen und Loeschkonzept
|
||||||
|
- **DSR** (Betroffenenanfragen) — Art. 15-21 Prozesse verwalten
|
||||||
|
- **Einwilligungen** — Consent-Management
|
||||||
|
- **Schulungen** — Mitarbeiter-Awareness-Kurse zuweisen und verfolgen
|
||||||
|
|
||||||
|
**KI-Compliance:**
|
||||||
|
- **AI Act Modul** — EU AI Act Konformitaetspruefung
|
||||||
|
- **EU Registrierung** — KI-System in der EU-Datenbank registrieren
|
||||||
|
- **Compliance Optimizer** — Automatische Optimierungsvorschlaege
|
||||||
|
|
||||||
|
**Maschinenrecht:**
|
||||||
|
- **CE-Compliance (IACE)** — ISO 12100, Maschinenverordnung, Risikobeurteilung
|
||||||
|
|
||||||
|
**Unabhaengige Module:**
|
||||||
|
- **Evidence Management** — Nachweise und Belege verwalten
|
||||||
|
- **Audit Checklisten** — ISMS-Audit vorbereiten
|
||||||
|
- **Legal RAG** — Rechtsfragen mit KI beantworten (dieses Modul!)
|
||||||
|
- **Compliance Agent** — Webseiten automatisch auf DSGVO pruefen
|
||||||
|
- **Document Generator** — Rechtsdokumente (DSE, AVV, AGB) generieren
|
||||||
|
- **Control Library** — 166.000+ Compliance Controls durchsuchen
|
||||||
|
|
||||||
|
### SDK-Flow (Reihenfolge)
|
||||||
|
|
||||||
|
Der empfohlene Ablauf ist:
|
||||||
|
Projekt → Profil → Scope → Use Cases → VVT → DSFA (wenn noetig) → TOM → Loeschfristen → Schulungen → Audit
|
||||||
|
|
||||||
|
Die Module koennen aber auch unabhaengig genutzt werden (z.B. Compliance Agent oder Document Generator).
|
||||||
|
|
||||||
|
### Hilfe und Navigation
|
||||||
|
|
||||||
|
- **Sidebar links** — Alle Module sind ueber die Sidebar erreichbar
|
||||||
|
- **CommandBar** (Cmd+K) — Schnellsuche ueber alle Module
|
||||||
|
- **Dieser Advisor** — Stellt Fragen zu Compliance-Themen oder zum SDK selbst
|
||||||
|
- **SDK-Flow Dokumentation** — Detaillierte Anleitung unter dem Menue-Punkt "SDK Flow"
|
||||||
|
|
||||||
|
## Haeufige Fragen (FAQ) — IAM-Systeme und Consent
|
||||||
|
|
||||||
|
### Was ist WSO2 Identity Server?
|
||||||
|
|
||||||
|
WSO2 Identity Server ist ein Open-Source Identity & Access Management (IAM) System,
|
||||||
|
vergleichbar mit Keycloak, Auth0 oder Azure AD B2C. Es wird von der Firma WSO2 Inc.
|
||||||
|
(Hauptsitz: Mountain View, USA + Colombo, Sri Lanka) entwickelt und gepflegt.
|
||||||
|
|
||||||
|
**DSGVO-Relevanz:** WSO2 IS liefert Standard-HTML-Templates fuer Login-, Registrierungs-
|
||||||
|
und Passwort-Reset-Seiten aus. Organisationen uebernehmen diese Templates oft 1:1 —
|
||||||
|
inklusive der Consent-Texte. Das fuehrt zu **systemischen Compliance-Problemen**:
|
||||||
|
|
||||||
|
- Die englischen Default-Texte sind bereits grenzwertig ("By clicking Register, you
|
||||||
|
agree to our Terms and Privacy Policy" — kein aktiver Opt-in)
|
||||||
|
- Uebersetzungen werden maschinell oder von Nicht-Juristen erstellt
|
||||||
|
- Niemand prueft ob die Formulierungen DSGVO-konform sind
|
||||||
|
- Das Pattern "Klick = Zustimmung" verletzt Art. 7(4) DSGVO (Koppelungsverbot)
|
||||||
|
und EuGH C-673/17 Planet49 (aktive Einwilligung erforderlich)
|
||||||
|
|
||||||
|
**Betroffene Organisationen:** EU-Behoerden (z.B. EUIPO), Regierungen, Telcos,
|
||||||
|
Banken, Versicherungen, Universitaeten — alle mit demselben Template-Fehler.
|
||||||
|
|
||||||
|
**Empfehlung:** Registrierungs- und Login-Seiten muessen geprueft werden auf:
|
||||||
|
1. Separate Checkboxen fuer Nutzungsbedingungen und Datenschutz (Granularitaet)
|
||||||
|
2. Aktive Zustimmungshandlung (Checkbox, nicht nur Button-Klick)
|
||||||
|
3. Moeglichkeit zur Ablehnung (Art. 7(3) DSGVO)
|
||||||
|
4. Grammatisch korrekte, verstaendliche Formulierung in der Sprache des Nutzers
|
||||||
|
5. Keine Koppelung von Einwilligung an Registrierung/Login (Art. 7(4) DSGVO)
|
||||||
|
|
||||||
|
### Welche IAM-Systeme haben aehnliche Probleme?
|
||||||
|
|
||||||
|
| System | Anbieter | Typisches Problem |
|
||||||
|
|--------|----------|-------------------|
|
||||||
|
| WSO2 Identity Server | WSO2 Inc. (US/LK) | Default-Templates mit Zwangs-Consent |
|
||||||
|
| Keycloak | Red Hat (US) | Kein Consent-Layer im Default-Theme |
|
||||||
|
| Azure AD B2C | Microsoft (US) | Custom Policies ohne DSGVO-Pruefung |
|
||||||
|
| Auth0 | Okta (US) | Universal Login ohne granularen Consent |
|
||||||
|
| AWS Cognito | Amazon (US) | Hosted UI ohne Consent-Management |
|
||||||
|
| ForgeRock | Ping Identity (US) | AM Templates ohne EU-Lokalisierung |
|
||||||
|
|
||||||
|
Alle diese Systeme erfordern manuelle Anpassung der Templates fuer DSGVO-Konformitaet.
|
||||||
|
Unser Compliance Agent kann Login/Registrierungsseiten auf diese Pattern pruefen.
|
||||||
|
|
||||||
|
### Was ist das Koppelungsverbot (Art. 7(4) DSGVO)?
|
||||||
|
|
||||||
|
Die Einwilligung zur Datenverarbeitung darf NICHT an die Erfuellung eines Vertrags
|
||||||
|
oder die Erbringung einer Dienstleistung gekoppelt werden, wenn die Datenverarbeitung
|
||||||
|
fuer die Vertragserfuellung nicht erforderlich ist.
|
||||||
|
|
||||||
|
**Praxis-Beispiel:** "Mit Klick auf Registrieren stimmen Sie unserer Datenschutzerklaerung zu"
|
||||||
|
ist ein Verstoss, wenn der Dienst auch ohne diese Zustimmung nutzbar waere.
|
||||||
|
|
||||||
|
**Korrekt:** Separate, freiwillige Checkbox: "Ich willige in die Verarbeitung meiner Daten
|
||||||
|
gemaess der Datenschutzerklaerung ein (freiwillig)."
|
||||||
|
|
||||||
|
**Quellen:** Art. 7(4) DSGVO, ErwGr. 43, EDPB Guidelines 05/2020 Rn. 26-30.
|
||||||
|
|
||||||
|
## CMP — Consent Management Platform
|
||||||
|
|
||||||
|
Das BreakPilot CMP ist die integrierte Consent-Management-Plattform im SDK.
|
||||||
|
Erreichbar ueber die CMP-Sektion in der Sidebar oder unter /sdk/cmp.
|
||||||
|
|
||||||
|
**Module:**
|
||||||
|
- **Dashboard** (/sdk/cmp) — Ueberblick ueber Consents, DSR, Compliance-Status
|
||||||
|
- **Cookie-Banner** (/sdk/cookie-banner) — Banner konfigurieren mit EWR-Only Toggle
|
||||||
|
- **Live-Vorschau** (/sdk/cookie-banner/preview) — Banner auf simulierter Website testen
|
||||||
|
- **Consent-Records** (/sdk/einwilligungen) — Alle Einwilligungen einsehen
|
||||||
|
- **Consent-Verwaltung** (/sdk/consent-management) — Dokument-Lifecycle
|
||||||
|
- **Vendor-Compliance** (/sdk/vendor-compliance) — Dienstleister-Management
|
||||||
|
- **DSR Portal** (/sdk/dsr) — Betroffenenrechte Art. 15-21
|
||||||
|
- **Loeschfristen** (/sdk/loeschfristen) — Aufbewahrungsrichtlinien
|
||||||
|
- **E-Mail-Templates** (/sdk/email-templates) — Benachrichtigungsvorlagen
|
||||||
|
|
||||||
|
**Einzigartiges Feature: "Nur EU/EWR" Toggle**
|
||||||
|
Nutzer koennen einer Cookie-Kategorie zustimmen (z.B. Marketing), aber gleichzeitig
|
||||||
|
alle Anbieter ausserhalb des EWR blockieren. Beispiel: Marketing = AN, EWR-Only = AN
|
||||||
|
bedeutet LinkedIn Insight (EU/Irland) wird geladen, Facebook Pixel (USA) wird blockiert.
|
||||||
|
Kein anderes CMP bietet dieses Feature.
|
||||||
|
|
||||||
## Eskalation
|
## Eskalation
|
||||||
- Bei Fragen ausserhalb des Kompetenzbereichs: Hoeflich ablehnen und auf Fachanwalt verweisen
|
- Bei Fragen ausserhalb des Kompetenzbereichs: Hoeflich ablehnen und auf Fachanwalt verweisen
|
||||||
- Bei widerspruechlichen Rechtslagen: Beide Positionen darstellen und DSB-Konsultation empfehlen
|
- Bei widerspruechlichen Rechtslagen: Beide Positionen darstellen und DSB-Konsultation empfehlen
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* Agent Analyze API Proxy
|
||||||
|
* POST /api/sdk/v1/agent/analyze → backend-compliance /api/compliance/agent/analyze
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
const BACKEND_URL = process.env.BACKEND_API_URL || 'http://backend-compliance:8002'
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.text()
|
||||||
|
|
||||||
|
const response = await fetch(`${BACKEND_URL}/api/compliance/agent/analyze`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Tenant-Id': '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e',
|
||||||
|
'X-User-Id': '00000000-0000-0000-0000-000000000001',
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
signal: AbortSignal.timeout(120000), // 2 min — LLM can be slow
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text()
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Backend: ${response.status}`, detail: errorText },
|
||||||
|
{ status: response.status }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
return NextResponse.json(data)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Agent analyze proxy error:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Verbindung zum Backend fehlgeschlagen' },
|
||||||
|
{ status: 503 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* Agent Doc-Check Proxy — Multi-URL document verification
|
||||||
|
* POST: start check, GET: poll status
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
const BACKEND_URL = process.env.BACKEND_API_URL || 'http://backend-compliance:8002'
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.text()
|
||||||
|
const response = await fetch(`${BACKEND_URL}/api/compliance/agent/doc-check`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body,
|
||||||
|
signal: AbortSignal.timeout(30000),
|
||||||
|
})
|
||||||
|
const data = await response.json()
|
||||||
|
return NextResponse.json(data, { status: response.status })
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({ error: 'Pruefung konnte nicht gestartet werden' }, { status: 503 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const checkId = request.nextUrl.searchParams.get('check_id')
|
||||||
|
if (!checkId) return NextResponse.json({ error: 'check_id required' }, { status: 400 })
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${BACKEND_URL}/api/compliance/agent/doc-check/${checkId}`,
|
||||||
|
{ signal: AbortSignal.timeout(10000) },
|
||||||
|
)
|
||||||
|
const data = await response.json()
|
||||||
|
return NextResponse.json(data)
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Status-Abfrage fehlgeschlagen' }, { status: 503 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* Agent Scan API Proxy — async scan with polling
|
||||||
|
*
|
||||||
|
* POST /api/sdk/v1/agent/scan → starts scan, returns scan_id
|
||||||
|
* GET /api/sdk/v1/agent/scan?scan_id=xxx → poll status/results
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
const BACKEND_URL = process.env.BACKEND_API_URL || 'http://backend-compliance:8002'
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.text()
|
||||||
|
|
||||||
|
// Start async scan — returns immediately with scan_id
|
||||||
|
const response = await fetch(`${BACKEND_URL}/api/compliance/agent/scan`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body,
|
||||||
|
signal: AbortSignal.timeout(30000), // 30s — just needs to start the job
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text()
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Backend: ${response.status}`, detail: errorText },
|
||||||
|
{ status: response.status }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
return NextResponse.json(data)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Agent scan proxy error:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Scan konnte nicht gestartet werden' },
|
||||||
|
{ status: 503 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const scanId = request.nextUrl.searchParams.get('scan_id')
|
||||||
|
if (!scanId) {
|
||||||
|
return NextResponse.json({ error: 'scan_id parameter required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${BACKEND_URL}/api/compliance/agent/scan/${scanId}`,
|
||||||
|
{ signal: AbortSignal.timeout(10000) }
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Backend: ${response.status}` },
|
||||||
|
{ status: response.status }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
return NextResponse.json(data)
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Status-Abfrage fehlgeschlagen' },
|
||||||
|
{ status: 503 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* Banner API Proxy — catch-all route for cookie banner endpoints.
|
||||||
|
*
|
||||||
|
* Maps: /api/sdk/v1/banner/<path> → backend-compliance:8002/api/compliance/banner/<path>
|
||||||
|
*
|
||||||
|
* Solves: Browser cannot call backend-compliance:8093 directly due to
|
||||||
|
* self-signed SSL certificates. This proxy runs server-side where
|
||||||
|
* certificate validation is not an issue.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||||
|
const DEFAULT_TENANT = process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||||
|
|
||||||
|
async function proxyRequest(
|
||||||
|
request: NextRequest,
|
||||||
|
pathSegments: string[] | undefined,
|
||||||
|
method: string,
|
||||||
|
) {
|
||||||
|
const pathStr = pathSegments?.join('/') || ''
|
||||||
|
const qs = request.nextUrl.searchParams.toString()
|
||||||
|
const base = `${BACKEND_URL}/api/compliance/banner`
|
||||||
|
const url = pathStr
|
||||||
|
? `${base}/${pathStr}${qs ? `?${qs}` : ''}`
|
||||||
|
: `${base}${qs ? `?${qs}` : ''}`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const headers: HeadersInit = {
|
||||||
|
'X-Tenant-ID': request.headers.get('x-tenant-id') || DEFAULT_TENANT,
|
||||||
|
}
|
||||||
|
const ct = request.headers.get('Content-Type')
|
||||||
|
if (ct) headers['Content-Type'] = ct
|
||||||
|
|
||||||
|
const opts: RequestInit = { method, headers, signal: AbortSignal.timeout(30000) }
|
||||||
|
|
||||||
|
if (method === 'POST' || method === 'PUT') {
|
||||||
|
const body = await request.text()
|
||||||
|
if (body) opts.body = body
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(url, opts)
|
||||||
|
const text = await res.text()
|
||||||
|
let data
|
||||||
|
try { data = JSON.parse(text) } catch { data = { raw: text } }
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Backend ${res.status}`, ...data },
|
||||||
|
{ status: res.status },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return NextResponse.json(data)
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Banner proxy error:', err?.message)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Backend nicht erreichbar' },
|
||||||
|
{ status: 503 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest, { params }: { params: Promise<{ path?: string[] }> }) {
|
||||||
|
return proxyRequest(req, (await params).path, 'GET')
|
||||||
|
}
|
||||||
|
export async function POST(req: NextRequest, { params }: { params: Promise<{ path?: string[] }> }) {
|
||||||
|
return proxyRequest(req, (await params).path, 'POST')
|
||||||
|
}
|
||||||
|
export async function PUT(req: NextRequest, { params }: { params: Promise<{ path?: string[] }> }) {
|
||||||
|
return proxyRequest(req, (await params).path, 'PUT')
|
||||||
|
}
|
||||||
|
export async function DELETE(req: NextRequest, { params }: { params: Promise<{ path?: string[] }> }) {
|
||||||
|
return proxyRequest(req, (await params).path, 'DELETE')
|
||||||
|
}
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
/**
|
|
||||||
* Demo Data Clear API Endpoint
|
|
||||||
*
|
|
||||||
* Clears demo data from the storage (same mechanism as real customer data).
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
|
||||||
|
|
||||||
// Shared store reference (same as seed endpoint)
|
|
||||||
declare global {
|
|
||||||
// eslint-disable-next-line no-var
|
|
||||||
var demoStateStore: Map<string, { state: unknown; version: number; updatedAt: Date }> | undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!global.demoStateStore) {
|
|
||||||
global.demoStateStore = new Map()
|
|
||||||
}
|
|
||||||
|
|
||||||
const stateStore = global.demoStateStore
|
|
||||||
|
|
||||||
export async function DELETE(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const body = await request.json()
|
|
||||||
const { tenantId = 'demo-tenant' } = body
|
|
||||||
|
|
||||||
const existed = stateStore.has(tenantId)
|
|
||||||
stateStore.delete(tenantId)
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
message: existed
|
|
||||||
? `Demo data cleared for tenant ${tenantId}`
|
|
||||||
: `No data found for tenant ${tenantId}`,
|
|
||||||
tenantId,
|
|
||||||
existed,
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to clear demo data:', error)
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Unknown error',
|
|
||||||
},
|
|
||||||
{ status: 500 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
// Also support POST for clearing (for clients that don't support DELETE)
|
|
||||||
return DELETE(request)
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
/**
|
|
||||||
* Demo Data Seed API Endpoint
|
|
||||||
*
|
|
||||||
* This endpoint seeds demo data via the same storage mechanism as real customer data.
|
|
||||||
* Demo data is NOT hardcoded - it goes through the normal API/database path.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
|
||||||
import { generateDemoState } from '@/lib/sdk/demo-data'
|
|
||||||
|
|
||||||
// In-memory store (same as state endpoint - will be replaced with PostgreSQL)
|
|
||||||
declare global {
|
|
||||||
// eslint-disable-next-line no-var
|
|
||||||
var demoStateStore: Map<string, { state: unknown; version: number; updatedAt: Date }> | undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!global.demoStateStore) {
|
|
||||||
global.demoStateStore = new Map()
|
|
||||||
}
|
|
||||||
|
|
||||||
const stateStore = global.demoStateStore
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const body = await request.json()
|
|
||||||
const { tenantId = 'demo-tenant', userId = 'demo-user' } = body
|
|
||||||
|
|
||||||
// Generate demo state using the seed data templates
|
|
||||||
const demoState = generateDemoState(tenantId, userId)
|
|
||||||
|
|
||||||
// Store via the same mechanism as real data
|
|
||||||
const storedState = {
|
|
||||||
state: demoState,
|
|
||||||
version: 1,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
}
|
|
||||||
|
|
||||||
stateStore.set(tenantId, storedState)
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
message: `Demo data seeded for tenant ${tenantId}`,
|
|
||||||
tenantId,
|
|
||||||
version: 1,
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to seed demo data:', error)
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Unknown error',
|
|
||||||
},
|
|
||||||
{ status: 500 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
const { searchParams } = new URL(request.url)
|
|
||||||
const tenantId = searchParams.get('tenantId') || 'demo-tenant'
|
|
||||||
|
|
||||||
const stored = stateStore.get(tenantId)
|
|
||||||
|
|
||||||
if (!stored) {
|
|
||||||
return NextResponse.json({
|
|
||||||
hasData: false,
|
|
||||||
tenantId,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
hasData: true,
|
|
||||||
tenantId,
|
|
||||||
version: stored.version,
|
|
||||||
updatedAt: stored.updatedAt,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -30,15 +30,15 @@ async function proxyRequest(
|
|||||||
headers['Authorization'] = authHeader
|
headers['Authorization'] = authHeader
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Default tenant/user for IACE (same pattern as training proxy)
|
||||||
|
const DEFAULT_TENANT = process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||||
|
const DEFAULT_USER = '00000000-0000-0000-0000-000000000001'
|
||||||
|
|
||||||
const tenantHeader = request.headers.get('x-tenant-id')
|
const tenantHeader = request.headers.get('x-tenant-id')
|
||||||
if (tenantHeader) {
|
headers['X-Tenant-Id'] = tenantHeader || DEFAULT_TENANT
|
||||||
headers['X-Tenant-Id'] = tenantHeader
|
|
||||||
}
|
|
||||||
|
|
||||||
const userHeader = request.headers.get('x-user-id')
|
const userHeader = request.headers.get('x-user-id')
|
||||||
if (userHeader) {
|
headers['X-User-Id'] = userHeader || DEFAULT_USER
|
||||||
headers['X-User-Id'] = userHeader
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchOptions: RequestInit = {
|
const fetchOptions: RequestInit = {
|
||||||
method,
|
method,
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
const SDK_URL = process.env.SDK_URL || 'http://ai-compliance-sdk:8090'
|
||||||
|
const DEFAULT_TENANT_ID = process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||||
|
const DEFAULT_USER_ID = '00000000-0000-0000-0000-000000000001'
|
||||||
|
|
||||||
|
function buildUrl(request: NextRequest, params: { path?: string[] }) {
|
||||||
|
const subPath = params.path?.join('/') || ''
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
const qs = searchParams.toString()
|
||||||
|
return `${SDK_URL}/sdk/v1/maximizer/${subPath}${qs ? `?${qs}` : ''}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function forwardHeaders(request: NextRequest): Record<string, string> {
|
||||||
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||||
|
headers['X-Tenant-ID'] = request.headers.get('X-Tenant-ID') || DEFAULT_TENANT_ID
|
||||||
|
headers['X-User-ID'] = request.headers.get('X-User-ID') || DEFAULT_USER_ID
|
||||||
|
return headers
|
||||||
|
}
|
||||||
|
|
||||||
|
async function proxy(request: NextRequest, params: { path?: string[] }, method: string) {
|
||||||
|
try {
|
||||||
|
const url = buildUrl(request, params)
|
||||||
|
const init: RequestInit = { method, headers: forwardHeaders(request) }
|
||||||
|
if (method !== 'GET' && method !== 'DELETE') {
|
||||||
|
init.body = await request.text()
|
||||||
|
}
|
||||||
|
const response = await fetch(url, init)
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text()
|
||||||
|
return NextResponse.json({ error: 'Maximizer backend error', details: errorText }, { status: response.status })
|
||||||
|
}
|
||||||
|
if (response.status === 204) return new NextResponse(null, { status: 204 })
|
||||||
|
const data = await response.json()
|
||||||
|
return NextResponse.json(data)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Maximizer proxy error:', error)
|
||||||
|
return NextResponse.json({ error: 'Failed to connect to Maximizer backend' }, { status: 503 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest, { params }: { params: { path?: string[] } }) {
|
||||||
|
return proxy(request, params, 'GET')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest, { params }: { params: { path?: string[] } }) {
|
||||||
|
return proxy(request, params, 'POST')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: NextRequest, { params }: { params: { path?: string[] } }) {
|
||||||
|
return proxy(request, params, 'DELETE')
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
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 { searchParams } = new URL(request.url)
|
||||||
|
const endpoint = searchParams.get('endpoint') || 'controls'
|
||||||
|
const tenantId = request.headers.get('x-tenant-id') || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||||
|
|
||||||
|
let path: string
|
||||||
|
switch (endpoint) {
|
||||||
|
case 'controls':
|
||||||
|
const domain = searchParams.get('domain') || ''
|
||||||
|
path = `/sdk/v1/payment-compliance/controls${domain ? `?domain=${domain}` : ''}`
|
||||||
|
break
|
||||||
|
case 'assessments':
|
||||||
|
path = '/sdk/v1/payment-compliance/assessments'
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
path = '/sdk/v1/payment-compliance/controls'
|
||||||
|
}
|
||||||
|
|
||||||
|
const resp = await fetch(`${SDK_URL}${path}`, {
|
||||||
|
headers: { 'X-Tenant-ID': tenantId },
|
||||||
|
})
|
||||||
|
const data = await resp.json()
|
||||||
|
return NextResponse.json(data)
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json({ error: 'Failed to fetch' }, { 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/payment-compliance/assessments`, {
|
||||||
|
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' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
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/payment-compliance/tender/${id}`)
|
||||||
|
return NextResponse.json(await resp.json())
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Failed' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
try {
|
||||||
|
const { id } = await params
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
const action = searchParams.get('action') || 'extract'
|
||||||
|
const resp = await fetch(`${SDK_URL}/sdk/v1/payment-compliance/tender/${id}/${action}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
return NextResponse.json(await resp.json(), { status: resp.status })
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Failed' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
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/payment-compliance/tender`, {
|
||||||
|
headers: { 'X-Tenant-ID': tenantId },
|
||||||
|
})
|
||||||
|
return NextResponse.json(await resp.json())
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Failed' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const tenantId = request.headers.get('x-tenant-id') || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||||
|
const formData = await request.formData()
|
||||||
|
const resp = await fetch(`${SDK_URL}/sdk/v1/payment-compliance/tender/upload`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-Tenant-ID': tenantId },
|
||||||
|
body: formData,
|
||||||
|
})
|
||||||
|
return NextResponse.json(await resp.json(), { status: resp.status })
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Upload failed' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
const SDK_URL = process.env.SDK_URL || 'http://ai-compliance-sdk:8090'
|
||||||
|
const DEFAULT_TENANT_ID = process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
const qs = searchParams.toString()
|
||||||
|
const url = `${SDK_URL}/sdk/v1/regulatory-news${qs ? `?${qs}` : ''}`
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Tenant-ID': request.headers.get('X-Tenant-ID') || DEFAULT_TENANT_ID,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return NextResponse.json({ error: 'SDK error' }, { status: response.status })
|
||||||
|
}
|
||||||
|
return NextResponse.json(await response.json())
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Connection failed' }, { status: 503 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -92,11 +92,17 @@ class PostgreSQLStateStore implements StateStore {
|
|||||||
private pool: Pool
|
private pool: Pool
|
||||||
|
|
||||||
constructor(connectionString: string) {
|
constructor(connectionString: string) {
|
||||||
|
// Strip sslmode from URL — pg driver overrides our ssl config if it's in the URL.
|
||||||
|
// We handle SSL ourselves via the ssl option below.
|
||||||
|
const cleanUrl = connectionString.replace(/[?&]sslmode=[^&]*/g, '').replace(/\?$/, '')
|
||||||
|
const needsSsl = connectionString.includes('sslmode=require') || connectionString.includes('sslmode=verify')
|
||||||
this.pool = new Pool({
|
this.pool = new Pool({
|
||||||
connectionString,
|
connectionString: cleanUrl,
|
||||||
max: 5,
|
max: 5,
|
||||||
// Set search_path for compliance schema
|
// Set search_path for compliance schema
|
||||||
options: '-c search_path=compliance,core,public',
|
options: '-c search_path=compliance,core,public',
|
||||||
|
// Accept self-signed certificates (Hetzner PostgreSQL)
|
||||||
|
ssl: needsSsl ? { rejectUnauthorized: false } : false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
const SDK_URL = process.env.SDK_URL || 'http://ai-compliance-sdk:8090'
|
||||||
|
const DEFAULT_TENANT_ID = process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proxy: POST /api/sdk/v1/ucca/assess-enriched → Go Backend POST /sdk/v1/ucca/assess-enriched
|
||||||
|
* Accepts { intake, company_profile? } and returns enriched assessment with obligations + hints.
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json()
|
||||||
|
|
||||||
|
const response = await fetch(`${SDK_URL}/sdk/v1/ucca/assess-enriched`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Tenant-ID': request.headers.get('X-Tenant-ID') || DEFAULT_TENANT_ID,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text()
|
||||||
|
console.error('UCCA assess-enriched error:', errorText)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'UCCA backend error', details: errorText },
|
||||||
|
{ status: response.status }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
return NextResponse.json(data, { status: 201 })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to call UCCA assess-enriched:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to connect to UCCA backend' },
|
||||||
|
{ status: 503 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
const SDK_URL = process.env.SDK_URL || 'http://ai-compliance-sdk:8090'
|
const SDK_URL = process.env.SDK_URL || 'http://ai-compliance-sdk:8090'
|
||||||
|
const DEFAULT_TENANT_ID = process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Proxy: GET /api/sdk/v1/ucca/assessments/[id] → Go Backend GET /sdk/v1/ucca/assessments/:id
|
* Proxy: GET /api/sdk/v1/ucca/assessments/[id] → Go Backend GET /sdk/v1/ucca/assessments/:id
|
||||||
@@ -16,9 +17,7 @@ export async function GET(
|
|||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...(request.headers.get('X-Tenant-ID') && {
|
'X-Tenant-ID': request.headers.get('X-Tenant-ID') || DEFAULT_TENANT_ID,
|
||||||
'X-Tenant-ID': request.headers.get('X-Tenant-ID') as string,
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -56,9 +55,7 @@ export async function PUT(
|
|||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...(request.headers.get('X-Tenant-ID') && {
|
'X-Tenant-ID': request.headers.get('X-Tenant-ID') || DEFAULT_TENANT_ID,
|
||||||
'X-Tenant-ID': request.headers.get('X-Tenant-ID') as string,
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
})
|
})
|
||||||
@@ -96,9 +93,7 @@ export async function DELETE(
|
|||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...(request.headers.get('X-Tenant-ID') && {
|
'X-Tenant-ID': request.headers.get('X-Tenant-ID') || DEFAULT_TENANT_ID,
|
||||||
'X-Tenant-ID': request.headers.get('X-Tenant-ID') as string,
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
const SDK_URL = process.env.SDK_URL || 'http://ai-compliance-sdk:8090'
|
const SDK_URL = process.env.SDK_URL || 'http://ai-compliance-sdk:8090'
|
||||||
|
const DEFAULT_TENANT_ID = process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Proxy: GET /api/sdk/v1/ucca/assessments → Go Backend GET /sdk/v1/ucca/assessments
|
* Proxy: GET /api/sdk/v1/ucca/assessments → Go Backend GET /sdk/v1/ucca/assessments
|
||||||
@@ -22,9 +23,7 @@ export async function GET(request: NextRequest) {
|
|||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...(request.headers.get('X-Tenant-ID') && {
|
'X-Tenant-ID': request.headers.get('X-Tenant-ID') || DEFAULT_TENANT_ID,
|
||||||
'X-Tenant-ID': request.headers.get('X-Tenant-ID') as string,
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
const SDK_URL = process.env.SDK_URL || 'http://ai-compliance-sdk:8090'
|
||||||
|
const DEFAULT_TENANT = process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proxy: /api/sdk/v1/ucca/decision-tree/... → Go Backend /sdk/v1/ucca/decision-tree/...
|
||||||
|
*/
|
||||||
|
async function proxyRequest(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
|
||||||
|
const { path } = await params
|
||||||
|
const subPath = path ? path.join('/') : ''
|
||||||
|
const search = request.nextUrl.search || ''
|
||||||
|
const targetUrl = `${SDK_URL}/sdk/v1/ucca/decision-tree/${subPath}${search}`
|
||||||
|
|
||||||
|
const tenantID = request.headers.get('X-Tenant-ID') || DEFAULT_TENANT
|
||||||
|
|
||||||
|
try {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'X-Tenant-ID': tenantID,
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchOptions: RequestInit = {
|
||||||
|
method: request.method,
|
||||||
|
headers,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method === 'POST' || request.method === 'PUT' || request.method === 'PATCH') {
|
||||||
|
const body = await request.json()
|
||||||
|
headers['Content-Type'] = 'application/json'
|
||||||
|
fetchOptions.body = JSON.stringify(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(targetUrl, fetchOptions)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text()
|
||||||
|
console.error(`Decision tree proxy error [${request.method} ${subPath}]:`, errorText)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Backend error', details: errorText },
|
||||||
|
{ status: response.status }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
return NextResponse.json(data, { status: response.status })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Decision tree proxy connection error:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to connect to AI compliance backend' },
|
||||||
|
{ status: 503 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GET = proxyRequest
|
||||||
|
export const POST = proxyRequest
|
||||||
|
export const DELETE = proxyRequest
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
const SDK_URL = process.env.SDK_URL || 'http://ai-compliance-sdk:8090'
|
||||||
|
const DEFAULT_TENANT = process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proxy: GET /api/sdk/v1/ucca/decision-tree → Go Backend GET /sdk/v1/ucca/decision-tree
|
||||||
|
* Returns the decision tree definition (questions, structure)
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const tenantID = request.headers.get('X-Tenant-ID') || DEFAULT_TENANT
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${SDK_URL}/sdk/v1/ucca/decision-tree`, {
|
||||||
|
headers: { 'X-Tenant-ID': tenantID },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text()
|
||||||
|
console.error('Decision tree GET error:', errorText)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Backend error', details: errorText },
|
||||||
|
{ status: response.status }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
return NextResponse.json(data)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Decision tree proxy error:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to connect to AI compliance backend' },
|
||||||
|
{ status: 503 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { AssessmentResultCard } from '@/components/sdk/use-case-assessment/AssessmentResultCard'
|
import { AssessmentResultCard } from '@/components/sdk/use-case-assessment/AssessmentResultCard'
|
||||||
|
import { OptimizerUpsellCard } from '@/components/sdk/compliance-optimizer/OptimizerUpsellCard'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
result: unknown
|
result: unknown
|
||||||
@@ -35,6 +36,13 @@ export function ResultView({ result, onGoToAssessment, onGoToOverview }: Props)
|
|||||||
{r.result && (
|
{r.result && (
|
||||||
<AssessmentResultCard result={r.result as unknown as Parameters<typeof AssessmentResultCard>[0]['result']} />
|
<AssessmentResultCard result={r.result as unknown as Parameters<typeof AssessmentResultCard>[0]['result']} />
|
||||||
)}
|
)}
|
||||||
|
{r.result && r.assessment?.id && (
|
||||||
|
<OptimizerUpsellCard
|
||||||
|
feasibility={(r.result as { feasibility?: string }).feasibility || 'YES'}
|
||||||
|
assessmentId={r.assessment.id}
|
||||||
|
riskScore={(r.result as { risk_score?: number }).risk_score}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,116 @@ export interface AdvisoryForm {
|
|||||||
custom_data_types: string[]
|
custom_data_types: string[]
|
||||||
purposes: string[]
|
purposes: string[]
|
||||||
automation: string
|
automation: string
|
||||||
|
// BetrVG / works council
|
||||||
|
employee_monitoring: boolean
|
||||||
|
hr_decision_support: boolean
|
||||||
|
works_council_consulted: boolean
|
||||||
|
// Domain-specific contexts (Annex III)
|
||||||
|
hr_automated_screening: boolean
|
||||||
|
hr_automated_rejection: boolean
|
||||||
|
hr_candidate_ranking: boolean
|
||||||
|
hr_bias_audits: boolean
|
||||||
|
hr_agg_visible: boolean
|
||||||
|
hr_human_review: boolean
|
||||||
|
hr_performance_eval: boolean
|
||||||
|
edu_grade_influence: boolean
|
||||||
|
edu_exam_evaluation: boolean
|
||||||
|
edu_student_selection: boolean
|
||||||
|
edu_minors: boolean
|
||||||
|
edu_teacher_review: boolean
|
||||||
|
hc_diagnosis: boolean
|
||||||
|
hc_treatment: boolean
|
||||||
|
hc_triage: boolean
|
||||||
|
hc_patient_data: boolean
|
||||||
|
hc_medical_device: boolean
|
||||||
|
hc_clinical_validation: boolean
|
||||||
|
// Legal
|
||||||
|
leg_legal_advice: boolean
|
||||||
|
leg_court_prediction: boolean
|
||||||
|
leg_client_confidential: boolean
|
||||||
|
// Public Sector
|
||||||
|
pub_admin_decision: boolean
|
||||||
|
pub_benefit_allocation: boolean
|
||||||
|
pub_transparency: boolean
|
||||||
|
// Critical Infrastructure
|
||||||
|
crit_grid_control: boolean
|
||||||
|
crit_safety_critical: boolean
|
||||||
|
crit_redundancy: boolean
|
||||||
|
// Automotive
|
||||||
|
auto_autonomous: boolean
|
||||||
|
auto_safety: boolean
|
||||||
|
auto_functional_safety: boolean
|
||||||
|
// Retail
|
||||||
|
ret_pricing: boolean
|
||||||
|
ret_profiling: boolean
|
||||||
|
ret_credit_scoring: boolean
|
||||||
|
ret_dark_patterns: boolean
|
||||||
|
// IT Security
|
||||||
|
its_surveillance: boolean
|
||||||
|
its_threat_detection: boolean
|
||||||
|
its_data_retention: boolean
|
||||||
|
// Logistics
|
||||||
|
log_driver_tracking: boolean
|
||||||
|
log_workload_scoring: boolean
|
||||||
|
// Construction
|
||||||
|
con_tenant_screening: boolean
|
||||||
|
con_worker_safety: boolean
|
||||||
|
// Marketing
|
||||||
|
mkt_deepfake: boolean
|
||||||
|
mkt_minors: boolean
|
||||||
|
mkt_targeting: boolean
|
||||||
|
mkt_labeled: boolean
|
||||||
|
// Manufacturing
|
||||||
|
mfg_machine_safety: boolean
|
||||||
|
mfg_ce_required: boolean
|
||||||
|
mfg_validated: boolean
|
||||||
|
// Agriculture
|
||||||
|
agr_pesticide: boolean
|
||||||
|
agr_animal_welfare: boolean
|
||||||
|
agr_environmental: boolean
|
||||||
|
// Social Services
|
||||||
|
soc_vulnerable: boolean
|
||||||
|
soc_benefit: boolean
|
||||||
|
soc_case_mgmt: boolean
|
||||||
|
// Hospitality
|
||||||
|
hos_guest_profiling: boolean
|
||||||
|
hos_dynamic_pricing: boolean
|
||||||
|
hos_review_manipulation: boolean
|
||||||
|
// Insurance
|
||||||
|
ins_risk_class: boolean
|
||||||
|
ins_claims: boolean
|
||||||
|
ins_premium: boolean
|
||||||
|
ins_fraud: boolean
|
||||||
|
// Investment
|
||||||
|
inv_algo_trading: boolean
|
||||||
|
inv_advice: boolean
|
||||||
|
inv_robo: boolean
|
||||||
|
// Defense
|
||||||
|
def_dual_use: boolean
|
||||||
|
def_export: boolean
|
||||||
|
def_classified: boolean
|
||||||
|
// Supply Chain
|
||||||
|
sch_supplier: boolean
|
||||||
|
sch_human_rights: boolean
|
||||||
|
sch_environmental: boolean
|
||||||
|
// Facility
|
||||||
|
fac_access: boolean
|
||||||
|
fac_occupancy: boolean
|
||||||
|
fac_energy: boolean
|
||||||
|
// Sports
|
||||||
|
spo_athlete: boolean
|
||||||
|
spo_fan: boolean
|
||||||
|
spo_doping: boolean
|
||||||
|
// Finance / Banking
|
||||||
|
fin_credit_scoring: boolean
|
||||||
|
fin_aml_kyc: boolean
|
||||||
|
fin_algo_decisions: boolean
|
||||||
|
fin_customer_profiling: boolean
|
||||||
|
// General
|
||||||
|
gen_affects_people: boolean
|
||||||
|
gen_automated_decisions: boolean
|
||||||
|
gen_sensitive_data: boolean
|
||||||
|
// Hosting
|
||||||
hosting_provider: string
|
hosting_provider: string
|
||||||
hosting_region: string
|
hosting_region: string
|
||||||
model_usage: string[]
|
model_usage: string[]
|
||||||
|
|||||||
@@ -51,6 +51,71 @@ function AdvisoryBoardPageInner() {
|
|||||||
custom_data_types: [],
|
custom_data_types: [],
|
||||||
purposes: [],
|
purposes: [],
|
||||||
automation: '',
|
automation: '',
|
||||||
|
// BetrVG / works council
|
||||||
|
employee_monitoring: false,
|
||||||
|
hr_decision_support: false,
|
||||||
|
works_council_consulted: false,
|
||||||
|
// Domain-specific contexts (Annex III)
|
||||||
|
hr_automated_screening: false,
|
||||||
|
hr_automated_rejection: false,
|
||||||
|
hr_candidate_ranking: false,
|
||||||
|
hr_bias_audits: false,
|
||||||
|
hr_agg_visible: false,
|
||||||
|
hr_human_review: false,
|
||||||
|
hr_performance_eval: false,
|
||||||
|
edu_grade_influence: false,
|
||||||
|
edu_exam_evaluation: false,
|
||||||
|
edu_student_selection: false,
|
||||||
|
edu_minors: false,
|
||||||
|
edu_teacher_review: false,
|
||||||
|
hc_diagnosis: false,
|
||||||
|
hc_treatment: false,
|
||||||
|
hc_triage: false,
|
||||||
|
hc_patient_data: false,
|
||||||
|
hc_medical_device: false,
|
||||||
|
hc_clinical_validation: false,
|
||||||
|
// Legal
|
||||||
|
leg_legal_advice: false, leg_court_prediction: false, leg_client_confidential: false,
|
||||||
|
// Public Sector
|
||||||
|
pub_admin_decision: false, pub_benefit_allocation: false, pub_transparency: false,
|
||||||
|
// Critical Infrastructure
|
||||||
|
crit_grid_control: false, crit_safety_critical: false, crit_redundancy: false,
|
||||||
|
// Automotive
|
||||||
|
auto_autonomous: false, auto_safety: false, auto_functional_safety: false,
|
||||||
|
// Retail
|
||||||
|
ret_pricing: false, ret_profiling: false, ret_credit_scoring: false, ret_dark_patterns: false,
|
||||||
|
// IT Security
|
||||||
|
its_surveillance: false, its_threat_detection: false, its_data_retention: false,
|
||||||
|
// Logistics
|
||||||
|
log_driver_tracking: false, log_workload_scoring: false,
|
||||||
|
// Construction
|
||||||
|
con_tenant_screening: false, con_worker_safety: false,
|
||||||
|
// Marketing
|
||||||
|
mkt_deepfake: false, mkt_minors: false, mkt_targeting: false, mkt_labeled: false,
|
||||||
|
// Manufacturing
|
||||||
|
mfg_machine_safety: false, mfg_ce_required: false, mfg_validated: false,
|
||||||
|
// Agriculture
|
||||||
|
agr_pesticide: false, agr_animal_welfare: false, agr_environmental: false,
|
||||||
|
// Social Services
|
||||||
|
soc_vulnerable: false, soc_benefit: false, soc_case_mgmt: false,
|
||||||
|
// Hospitality
|
||||||
|
hos_guest_profiling: false, hos_dynamic_pricing: false, hos_review_manipulation: false,
|
||||||
|
// Insurance
|
||||||
|
ins_risk_class: false, ins_claims: false, ins_premium: false, ins_fraud: false,
|
||||||
|
// Investment
|
||||||
|
inv_algo_trading: false, inv_advice: false, inv_robo: false,
|
||||||
|
// Defense
|
||||||
|
def_dual_use: false, def_export: false, def_classified: false,
|
||||||
|
// Supply Chain
|
||||||
|
sch_supplier: false, sch_human_rights: false, sch_environmental: false,
|
||||||
|
// Facility
|
||||||
|
fac_access: false, fac_occupancy: false, fac_energy: false,
|
||||||
|
// Sports
|
||||||
|
spo_athlete: false, spo_fan: false, spo_doping: false,
|
||||||
|
// Finance / Banking
|
||||||
|
fin_credit_scoring: false, fin_aml_kyc: false, fin_algo_decisions: false, fin_customer_profiling: false,
|
||||||
|
// General
|
||||||
|
gen_affects_people: false, gen_automated_decisions: false, gen_sensitive_data: false,
|
||||||
hosting_provider: '',
|
hosting_provider: '',
|
||||||
hosting_region: '',
|
hosting_region: '',
|
||||||
model_usage: [],
|
model_usage: [],
|
||||||
@@ -133,18 +198,164 @@ function AdvisoryBoardPageInner() {
|
|||||||
retention_purpose: form.retention_purpose,
|
retention_purpose: form.retention_purpose,
|
||||||
contracts_list: form.contracts,
|
contracts_list: form.contracts,
|
||||||
subprocessors: form.subprocessors,
|
subprocessors: form.subprocessors,
|
||||||
|
employee_monitoring: form.employee_monitoring,
|
||||||
|
hr_decision_support: form.hr_decision_support,
|
||||||
|
works_council_consulted: form.works_council_consulted,
|
||||||
|
// Domain-specific contexts
|
||||||
|
hr_context: ['hr', 'recruiting'].includes(form.domain) ? {
|
||||||
|
automated_screening: form.hr_automated_screening,
|
||||||
|
automated_rejection: form.hr_automated_rejection,
|
||||||
|
candidate_ranking: form.hr_candidate_ranking,
|
||||||
|
bias_audits_done: form.hr_bias_audits,
|
||||||
|
agg_categories_visible: form.hr_agg_visible,
|
||||||
|
human_review_enforced: form.hr_human_review,
|
||||||
|
performance_evaluation: form.hr_performance_eval,
|
||||||
|
} : undefined,
|
||||||
|
education_context: ['education', 'higher_education', 'vocational_training', 'research'].includes(form.domain) ? {
|
||||||
|
grade_influence: form.edu_grade_influence,
|
||||||
|
exam_evaluation: form.edu_exam_evaluation,
|
||||||
|
student_selection: form.edu_student_selection,
|
||||||
|
minors_involved: form.edu_minors,
|
||||||
|
teacher_review_required: form.edu_teacher_review,
|
||||||
|
} : undefined,
|
||||||
|
healthcare_context: ['healthcare', 'medical_devices', 'pharma', 'elderly_care'].includes(form.domain) ? {
|
||||||
|
diagnosis_support: form.hc_diagnosis,
|
||||||
|
treatment_recommendation: form.hc_treatment,
|
||||||
|
triage_decision: form.hc_triage,
|
||||||
|
patient_data_processed: form.hc_patient_data,
|
||||||
|
medical_device: form.hc_medical_device,
|
||||||
|
clinical_validation: form.hc_clinical_validation,
|
||||||
|
} : undefined,
|
||||||
|
legal_context: ['legal', 'consulting', 'tax_advisory'].includes(form.domain) ? {
|
||||||
|
legal_advice: form.leg_legal_advice,
|
||||||
|
court_prediction: form.leg_court_prediction,
|
||||||
|
client_confidential: form.leg_client_confidential,
|
||||||
|
} : undefined,
|
||||||
|
public_sector_context: ['public_sector', 'defense', 'justice'].includes(form.domain) ? {
|
||||||
|
admin_decision: form.pub_admin_decision,
|
||||||
|
benefit_allocation: form.pub_benefit_allocation,
|
||||||
|
transparency_ensured: form.pub_transparency,
|
||||||
|
} : undefined,
|
||||||
|
critical_infra_context: ['energy', 'utilities', 'oil_gas'].includes(form.domain) ? {
|
||||||
|
grid_control: form.crit_grid_control,
|
||||||
|
safety_critical: form.crit_safety_critical,
|
||||||
|
redundancy_exists: form.crit_redundancy,
|
||||||
|
} : undefined,
|
||||||
|
automotive_context: ['automotive', 'aerospace'].includes(form.domain) ? {
|
||||||
|
autonomous_driving: form.auto_autonomous,
|
||||||
|
safety_relevant: form.auto_safety,
|
||||||
|
functional_safety: form.auto_functional_safety,
|
||||||
|
} : undefined,
|
||||||
|
retail_context: ['retail', 'ecommerce', 'wholesale'].includes(form.domain) ? {
|
||||||
|
pricing_personalized: form.ret_pricing,
|
||||||
|
credit_scoring: form.ret_credit_scoring,
|
||||||
|
dark_patterns: form.ret_dark_patterns,
|
||||||
|
} : undefined,
|
||||||
|
it_security_context: ['it_services', 'cybersecurity', 'telecom'].includes(form.domain) ? {
|
||||||
|
employee_surveillance: form.its_surveillance,
|
||||||
|
threat_detection: form.its_threat_detection,
|
||||||
|
data_retention_logs: form.its_data_retention,
|
||||||
|
} : undefined,
|
||||||
|
logistics_context: ['logistics'].includes(form.domain) ? {
|
||||||
|
driver_tracking: form.log_driver_tracking,
|
||||||
|
workload_scoring: form.log_workload_scoring,
|
||||||
|
} : undefined,
|
||||||
|
construction_context: ['construction', 'real_estate', 'facility_management'].includes(form.domain) ? {
|
||||||
|
tenant_screening: form.con_tenant_screening,
|
||||||
|
worker_safety: form.con_worker_safety,
|
||||||
|
} : undefined,
|
||||||
|
marketing_context: ['marketing', 'media', 'entertainment'].includes(form.domain) ? {
|
||||||
|
deepfake_content: form.mkt_deepfake,
|
||||||
|
behavioral_targeting: form.mkt_targeting,
|
||||||
|
minors_targeted: form.mkt_minors,
|
||||||
|
ai_content_labeled: form.mkt_labeled,
|
||||||
|
} : undefined,
|
||||||
|
manufacturing_context: ['mechanical_engineering', 'electrical_engineering', 'plant_engineering', 'chemicals', 'food_beverage'].includes(form.domain) ? {
|
||||||
|
machine_safety: form.mfg_machine_safety,
|
||||||
|
ce_marking_required: form.mfg_ce_required,
|
||||||
|
safety_validated: form.mfg_validated,
|
||||||
|
} : undefined,
|
||||||
|
agriculture_context: ['agriculture', 'forestry', 'fishing'].includes(form.domain) ? {
|
||||||
|
pesticide_ai: form.agr_pesticide,
|
||||||
|
animal_welfare: form.agr_animal_welfare,
|
||||||
|
environmental_data: form.agr_environmental,
|
||||||
|
} : undefined,
|
||||||
|
social_services_context: ['social_services', 'nonprofit'].includes(form.domain) ? {
|
||||||
|
vulnerable_groups: form.soc_vulnerable,
|
||||||
|
benefit_decision: form.soc_benefit,
|
||||||
|
case_management: form.soc_case_mgmt,
|
||||||
|
} : undefined,
|
||||||
|
hospitality_context: ['hospitality', 'tourism'].includes(form.domain) ? {
|
||||||
|
guest_profiling: form.hos_guest_profiling,
|
||||||
|
dynamic_pricing: form.hos_dynamic_pricing,
|
||||||
|
review_manipulation: form.hos_review_manipulation,
|
||||||
|
} : undefined,
|
||||||
|
insurance_context: ['insurance'].includes(form.domain) ? {
|
||||||
|
risk_classification: form.ins_risk_class,
|
||||||
|
claims_automation: form.ins_claims,
|
||||||
|
premium_calculation: form.ins_premium,
|
||||||
|
fraud_detection: form.ins_fraud,
|
||||||
|
} : undefined,
|
||||||
|
investment_context: ['investment'].includes(form.domain) ? {
|
||||||
|
algo_trading: form.inv_algo_trading,
|
||||||
|
investment_advice: form.inv_advice,
|
||||||
|
robo_advisor: form.inv_robo,
|
||||||
|
} : undefined,
|
||||||
|
defense_context: ['defense'].includes(form.domain) ? {
|
||||||
|
dual_use: form.def_dual_use,
|
||||||
|
export_controlled: form.def_export,
|
||||||
|
classified_data: form.def_classified,
|
||||||
|
} : undefined,
|
||||||
|
supply_chain_context: ['textiles', 'packaging'].includes(form.domain) ? {
|
||||||
|
supplier_monitoring: form.sch_supplier,
|
||||||
|
human_rights_check: form.sch_human_rights,
|
||||||
|
environmental_impact: form.sch_environmental,
|
||||||
|
} : undefined,
|
||||||
|
facility_context: ['facility_management'].includes(form.domain) ? {
|
||||||
|
access_control_ai: form.fac_access,
|
||||||
|
occupancy_tracking: form.fac_occupancy,
|
||||||
|
energy_optimization: form.fac_energy,
|
||||||
|
} : undefined,
|
||||||
|
sports_context: ['sports'].includes(form.domain) ? {
|
||||||
|
athlete_tracking: form.spo_athlete,
|
||||||
|
fan_profiling: form.spo_fan,
|
||||||
|
} : undefined,
|
||||||
store_raw_text: true,
|
store_raw_text: true,
|
||||||
|
// Finance/Banking and General don't need separate context structs —
|
||||||
|
// their fields are evaluated via existing FinancialContext or generic rules
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = isEditMode
|
const url = isEditMode
|
||||||
? `/api/sdk/v1/ucca/assessments/${editId}`
|
? `/api/sdk/v1/ucca/assessments/${editId}`
|
||||||
: '/api/sdk/v1/ucca/assess'
|
: '/api/sdk/v1/ucca/assess-enriched'
|
||||||
const method = isEditMode ? 'PUT' : 'POST'
|
const method = isEditMode ? 'PUT' : 'POST'
|
||||||
|
|
||||||
|
// For new assessments, send enriched payload with company profile
|
||||||
|
const payload = isEditMode ? intake : {
|
||||||
|
intake,
|
||||||
|
company_profile: sdkState.companyProfile ? {
|
||||||
|
company_name: sdkState.companyProfile.companyName ?? '',
|
||||||
|
legal_form: sdkState.companyProfile.legalForm ?? '',
|
||||||
|
industry: Array.isArray(sdkState.companyProfile.industry)
|
||||||
|
? sdkState.companyProfile.industry.join(', ')
|
||||||
|
: (sdkState.companyProfile.industry ?? ''),
|
||||||
|
employee_count: sdkState.companyProfile.employeeCount ?? '',
|
||||||
|
annual_revenue: sdkState.companyProfile.annualRevenue ?? '',
|
||||||
|
headquarters_country: sdkState.companyProfile.headquartersCountry ?? 'DE',
|
||||||
|
is_data_controller: sdkState.companyProfile.isDataController ?? true,
|
||||||
|
is_data_processor: sdkState.companyProfile.isDataProcessor ?? false,
|
||||||
|
uses_ai: true,
|
||||||
|
dpo_name: sdkState.companyProfile.dpoName ?? null,
|
||||||
|
subject_to_nis2: false,
|
||||||
|
subject_to_ai_act: false,
|
||||||
|
subject_to_iso27001: false,
|
||||||
|
} : undefined,
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method,
|
method,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(intake),
|
body: JSON.stringify(payload),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
import type { AnalysisResult } from '../_hooks/useAgentAnalysis'
|
||||||
|
|
||||||
|
const DOC_TYPE_LABELS: Record<string, string> = {
|
||||||
|
privacy_policy: 'DSE',
|
||||||
|
cookie_banner: 'Cookie',
|
||||||
|
terms_of_service: 'AGB',
|
||||||
|
imprint: 'Impressum',
|
||||||
|
dpa: 'AVV',
|
||||||
|
other: 'Sonstig',
|
||||||
|
}
|
||||||
|
|
||||||
|
const RISK_DOT: Record<string, string> = {
|
||||||
|
low: 'bg-green-500',
|
||||||
|
medium: 'bg-yellow-500',
|
||||||
|
high: 'bg-orange-500',
|
||||||
|
critical: 'bg-red-500',
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
history: AnalysisResult[]
|
||||||
|
onSelect: (result: AnalysisResult) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AnalysisHistory({ history, onSelect }: Props) {
|
||||||
|
if (history.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-gray-700 mb-3">Letzte Analysen</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{history.map((item, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => onSelect(item)}
|
||||||
|
className="w-full text-left p-3 bg-white border border-gray-200 rounded-lg hover:border-purple-300 hover:bg-purple-50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className={`w-2.5 h-2.5 rounded-full ${RISK_DOT[item.risk_level] || 'bg-gray-400'}`} />
|
||||||
|
<span className="text-xs font-medium text-gray-500 w-16">
|
||||||
|
{DOC_TYPE_LABELS[item.classification] || item.classification}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-gray-700 truncate flex-1">
|
||||||
|
{new URL(item.url).hostname}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-400">
|
||||||
|
{new Date(item.analyzed_at).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
import type { AnalysisResult as AnalysisResultType } from '../_hooks/useAgentAnalysis'
|
||||||
|
|
||||||
|
const RISK_COLORS: Record<string, { bg: string; text: string; label: string }> = {
|
||||||
|
low: { bg: 'bg-green-100', text: 'text-green-800', label: 'Niedrig' },
|
||||||
|
medium: { bg: 'bg-yellow-100', text: 'text-yellow-800', label: 'Mittel' },
|
||||||
|
high: { bg: 'bg-orange-100', text: 'text-orange-800', label: 'Hoch' },
|
||||||
|
critical: { bg: 'bg-red-100', text: 'text-red-800', label: 'Kritisch' },
|
||||||
|
unknown: { bg: 'bg-gray-100', text: 'text-gray-800', label: 'Unbekannt' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const DOC_TYPE_LABELS: Record<string, string> = {
|
||||||
|
privacy_policy: 'Datenschutzerklaerung',
|
||||||
|
cookie_banner: 'Cookie-Banner',
|
||||||
|
terms_of_service: 'AGB',
|
||||||
|
imprint: 'Impressum',
|
||||||
|
dpa: 'Auftragsverarbeitung (AVV)',
|
||||||
|
other: 'Sonstiges',
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
result: AnalysisResultType
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AnalysisResult({ result }: Props) {
|
||||||
|
const risk = RISK_COLORS[result.risk_level] || RISK_COLORS.unknown
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">
|
||||||
|
{DOC_TYPE_LABELS[result.classification] || result.classification}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-500 truncate max-w-md">{result.url}</p>
|
||||||
|
</div>
|
||||||
|
<span className={`px-3 py-1 rounded-full text-sm font-medium ${risk.bg} ${risk.text}`}>
|
||||||
|
{risk.label} ({result.risk_score}/100)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Role Assignment */}
|
||||||
|
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<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="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-sm font-medium text-purple-900">
|
||||||
|
Zugewiesen an: <strong>{result.responsible_role}</strong>
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-purple-600 ml-auto">
|
||||||
|
Eskalationsstufe {result.escalation_level}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary */}
|
||||||
|
{result.summary && (
|
||||||
|
<div className="bg-gray-50 rounded-lg p-4">
|
||||||
|
<h4 className="text-sm font-medium text-gray-700 mb-2">Zusammenfassung</h4>
|
||||||
|
<p className="text-sm text-gray-600 whitespace-pre-wrap">{result.summary}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Findings */}
|
||||||
|
{result.findings.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-gray-700 mb-2">Findings ({result.findings.length})</h4>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{result.findings.map((f, i) => (
|
||||||
|
<li key={i} className="flex items-start gap-2 text-sm text-gray-600">
|
||||||
|
<span className="text-orange-500 mt-0.5">!</span>
|
||||||
|
{f}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Required Controls */}
|
||||||
|
{result.required_controls.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-gray-700 mb-2">Erforderliche Massnahmen</h4>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{result.required_controls.map((c, i) => (
|
||||||
|
<li key={i} className="flex items-start gap-2 text-sm text-gray-600">
|
||||||
|
<span className="text-blue-500 mt-0.5">✓</span>
|
||||||
|
{c}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Email Status */}
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-500 pt-2 border-t">
|
||||||
|
<span className={result.email_status === 'sent' ? 'text-green-600' : 'text-yellow-600'}>
|
||||||
|
{result.email_status === 'sent' ? '✉ Email gesendet' : '✉ Email ausstehend'}
|
||||||
|
</span>
|
||||||
|
<span className="ml-auto text-xs">
|
||||||
|
{new Date(result.analyzed_at).toLocaleString('de-DE')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
|
||||||
|
interface CheckItem {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
passed: boolean
|
||||||
|
severity: string
|
||||||
|
matched_text: string
|
||||||
|
level?: number
|
||||||
|
parent?: string | null
|
||||||
|
skipped?: boolean
|
||||||
|
hint?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DocResult {
|
||||||
|
label: string
|
||||||
|
url: string
|
||||||
|
doc_type: string
|
||||||
|
word_count: number
|
||||||
|
completeness_pct: number
|
||||||
|
correctness_pct?: number
|
||||||
|
checks: CheckItem[]
|
||||||
|
findings_count: number
|
||||||
|
error: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const DOC_TYPE_LABELS: Record<string, string> = {
|
||||||
|
dse: 'DSI', agb: 'AGB', impressum: 'Impressum',
|
||||||
|
cookie: 'Cookie', widerruf: 'Widerruf', other: 'Sonstiges',
|
||||||
|
social_media: 'Social Media', dsfa: 'DSFA', joint_controller: 'Art. 26',
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GroupedCheck {
|
||||||
|
check: CheckItem
|
||||||
|
children: CheckItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupChecks(checks: CheckItem[]): GroupedCheck[] {
|
||||||
|
const l1 = checks.filter(c => (c.level ?? 1) === 1)
|
||||||
|
return l1.map(c => ({
|
||||||
|
check: c,
|
||||||
|
children: checks.filter(ch => ch.parent === c.id && (ch.level ?? 1) === 2),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function CheckIcon({ passed, skipped }: { passed: boolean; skipped?: boolean }) {
|
||||||
|
if (skipped) {
|
||||||
|
return (
|
||||||
|
<svg className="w-4 h-4 text-gray-300 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 12H4" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (passed) {
|
||||||
|
return (
|
||||||
|
<svg className="w-4 h-4 text-green-500 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<svg className="w-4 h-4 text-red-500 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function L2Summary({ children }: { children: CheckItem[] }) {
|
||||||
|
const active = children.filter(c => !c.skipped)
|
||||||
|
if (active.length === 0) return null
|
||||||
|
const passed = active.filter(c => c.passed).length
|
||||||
|
return (
|
||||||
|
<span className="text-xs text-gray-400 ml-1">
|
||||||
|
({passed}/{active.length})
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChecklistView({ results }: { results: DocResult[] }) {
|
||||||
|
const [expanded, setExpanded] = useState<number | null>(null)
|
||||||
|
|
||||||
|
if (!results || results.length === 0) return null
|
||||||
|
|
||||||
|
const totalOk = results.filter(r => r.completeness_pct === 100).length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-800">
|
||||||
|
Dokumenten-Pruefung ({results.length} Dokumente, {totalOk} vollstaendig)
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{results.map((r, i) => {
|
||||||
|
const isExp = expanded === i
|
||||||
|
const pct = r.completeness_pct
|
||||||
|
const cpct = r.correctness_pct ?? 0
|
||||||
|
const barColor = pct === 100 ? 'bg-green-500' : pct >= 80 ? 'bg-green-400' : pct >= 50 ? 'bg-yellow-500' : 'bg-red-500'
|
||||||
|
const cBarColor = cpct >= 80 ? 'bg-blue-400' : cpct >= 50 ? 'bg-blue-300' : 'bg-blue-200'
|
||||||
|
const typeLabel = DOC_TYPE_LABELS[r.doc_type] || r.doc_type
|
||||||
|
const grouped = groupChecks(r.checks)
|
||||||
|
const l1Checks = r.checks.filter(c => (c.level ?? 1) === 1)
|
||||||
|
const l2Active = r.checks.filter(c => (c.level ?? 1) === 2 && !c.skipped)
|
||||||
|
const l1Passed = l1Checks.filter(c => c.passed).length
|
||||||
|
const l2Passed = l2Active.filter(c => c.passed).length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={i} className="border border-gray-200 rounded-lg overflow-hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => setExpanded(isExp ? null : i)}
|
||||||
|
className="w-full flex items-center justify-between px-4 py-3 hover:bg-gray-50 text-left"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||||
|
<svg className={`w-4 h-4 text-gray-400 transition-transform shrink-0 ${isExp ? 'rotate-90' : ''}`}
|
||||||
|
fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded bg-gray-100 text-gray-600 font-medium shrink-0">
|
||||||
|
{typeLabel}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-sm font-medium text-gray-900 truncate">{r.label}</div>
|
||||||
|
<div className="text-xs text-gray-500 truncate">
|
||||||
|
{l1Checks.length > 0
|
||||||
|
? `${l1Passed}/${l1Checks.length} Pflichtangaben`
|
||||||
|
+ (l2Active.length > 0 ? `, ${l2Passed}/${l2Active.length} Detailpruefungen` : '')
|
||||||
|
: r.url}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 shrink-0 ml-3">
|
||||||
|
{r.error ? (
|
||||||
|
<span className="text-xs text-red-600 font-medium">Fehler</span>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-16 h-1.5 bg-gray-200 rounded-full overflow-hidden">
|
||||||
|
<div className={`h-full rounded-full ${barColor}`} style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className={`text-xs font-medium w-10 text-right ${
|
||||||
|
pct === 100 ? 'text-green-700' : pct >= 50 ? 'text-yellow-700' : 'text-red-700'
|
||||||
|
}`}>{pct}%</span>
|
||||||
|
</div>
|
||||||
|
{l2Active.length > 0 && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-16 h-1.5 bg-gray-200 rounded-full overflow-hidden">
|
||||||
|
<div className={`h-full rounded-full ${cBarColor}`} style={{ width: `${cpct}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-medium w-10 text-right text-blue-600">{cpct}%</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isExp && (
|
||||||
|
<div className="px-4 py-3 border-t border-gray-100 bg-gray-50/50">
|
||||||
|
{r.error ? (
|
||||||
|
<p className="text-sm text-red-600">{r.error}</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{grouped.map((g) => (
|
||||||
|
<div key={g.check.id}>
|
||||||
|
{/* L1 check */}
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<CheckIcon passed={g.check.passed} />
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className={`text-sm ${g.check.passed ? 'text-gray-700' : 'text-red-700 font-medium'}`}>
|
||||||
|
{g.check.label}
|
||||||
|
{g.children.length > 0 && <L2Summary>{g.children}</L2Summary>}
|
||||||
|
</div>
|
||||||
|
{g.check.passed && g.check.matched_text && g.children.length === 0 && (
|
||||||
|
<div className="text-xs text-gray-400 mt-0.5 font-mono truncate">
|
||||||
|
"...{g.check.matched_text}..."
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!g.check.passed && g.check.hint && (
|
||||||
|
<div className="text-xs text-red-600/80 mt-0.5">
|
||||||
|
{g.check.hint}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* L2 children — always visible */}
|
||||||
|
{g.children.length > 0 && (
|
||||||
|
<div className="ml-6 mt-0.5 mb-1 space-y-0.5 border-l-2 border-gray-200 pl-3">
|
||||||
|
{g.children.map((ch) => (
|
||||||
|
<div key={ch.id} className="flex items-start gap-2">
|
||||||
|
<CheckIcon passed={ch.passed} skipped={ch.skipped} />
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className={`text-xs ${
|
||||||
|
ch.skipped ? 'text-gray-400 italic'
|
||||||
|
: ch.passed ? 'text-gray-600' : 'text-red-600 font-medium'
|
||||||
|
}`}>
|
||||||
|
{ch.label}
|
||||||
|
{ch.skipped && ' (uebersprungen)'}
|
||||||
|
</div>
|
||||||
|
{ch.passed && ch.matched_text && (
|
||||||
|
<div className="text-xs text-gray-400 mt-0.5 font-mono truncate">
|
||||||
|
"...{ch.matched_text}..."
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!ch.passed && !ch.skipped && ch.hint && (
|
||||||
|
<div className="text-xs text-red-500/80 mt-0.5">
|
||||||
|
{ch.hint}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{r.word_count > 0 && (
|
||||||
|
<div className="text-xs text-gray-400 mt-2 pt-2 border-t border-gray-200">
|
||||||
|
{r.word_count} Woerter analysiert
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
import { ChecklistView } from './ChecklistView'
|
||||||
|
|
||||||
|
interface DocEntry {
|
||||||
|
id: string
|
||||||
|
type: string
|
||||||
|
label: string
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const DOC_TYPES = [
|
||||||
|
{ id: 'dse', label: 'DSI (Datenschutzinformation)' },
|
||||||
|
{ id: 'social_media', label: 'DSE Social Media (Art. 26)' },
|
||||||
|
{ id: 'dsfa', label: 'DSFA (Art. 35)' },
|
||||||
|
{ id: 'agb', label: 'AGB / Nutzungsbedingungen' },
|
||||||
|
{ id: 'impressum', label: 'Impressum' },
|
||||||
|
{ id: 'cookie', label: 'Cookie-Richtlinie' },
|
||||||
|
{ id: 'widerruf', label: 'Widerrufsbelehrung' },
|
||||||
|
{ id: 'other', label: 'Sonstiges' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function newEntry(): DocEntry {
|
||||||
|
return { id: crypto.randomUUID().slice(0, 8), type: 'dse', label: '', url: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DocCheckTab() {
|
||||||
|
const [entries, setEntries] = useState<DocEntry[]>(() => {
|
||||||
|
if (typeof window === 'undefined') return [newEntry()]
|
||||||
|
try { const s = localStorage.getItem('doc-check-entries'); return s ? JSON.parse(s) : [newEntry()] } catch { return [newEntry()] }
|
||||||
|
})
|
||||||
|
const [checkCookieBanner, setCheckCookieBanner] = useState(false)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [progress, setProgress] = useState('')
|
||||||
|
const [results, setResults] = useState<any>(() => {
|
||||||
|
if (typeof window === 'undefined') return null
|
||||||
|
try { const s = localStorage.getItem('doc-check-results'); return s ? JSON.parse(s) : null } catch { return null }
|
||||||
|
})
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [history, setHistory] = useState<{ date: string; urls: number; findings: number }[]>(() => {
|
||||||
|
if (typeof window === 'undefined') return []
|
||||||
|
try { return JSON.parse(localStorage.getItem('doc-check-history') || '[]') } catch { return [] }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Persist entries
|
||||||
|
React.useEffect(() => { localStorage.setItem('doc-check-entries', JSON.stringify(entries)) }, [entries])
|
||||||
|
|
||||||
|
const updateEntry = (id: string, field: keyof DocEntry, value: string) => {
|
||||||
|
setEntries(prev => prev.map(e => e.id === id ? { ...e, [field]: value } : e))
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeEntry = (id: string) => {
|
||||||
|
setEntries(prev => prev.filter(e => e.id !== id))
|
||||||
|
}
|
||||||
|
|
||||||
|
const addEntry = () => {
|
||||||
|
setEntries(prev => [...prev, newEntry()])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-detect label from URL
|
||||||
|
const autoLabel = (entry: DocEntry) => {
|
||||||
|
if (entry.label) return
|
||||||
|
try {
|
||||||
|
const path = new URL(entry.url).pathname
|
||||||
|
const last = path.split('/').filter(Boolean).pop() || ''
|
||||||
|
const label = last.replace(/-\d+$/, '').replace(/-/g, ' ')
|
||||||
|
.replace(/\b\w/g, c => c.toUpperCase())
|
||||||
|
if (label.length > 3) {
|
||||||
|
updateEntry(entry.id, 'label', label)
|
||||||
|
}
|
||||||
|
} catch { /* invalid URL */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
const validEntries = entries.filter(e => e.url.trim())
|
||||||
|
if (validEntries.length === 0) return
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
setResults(null)
|
||||||
|
setProgress('Pruefung wird gestartet...')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const startRes = await fetch('/api/sdk/v1/agent/doc-check', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
entries: validEntries.map(e => ({
|
||||||
|
doc_type: e.type,
|
||||||
|
label: e.label || e.url.split('/').pop() || 'Dokument',
|
||||||
|
url: e.url.trim(),
|
||||||
|
})),
|
||||||
|
check_cookie_banner: checkCookieBanner,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (!startRes.ok) throw new Error(`Pruefung konnte nicht gestartet werden: ${startRes.status}`)
|
||||||
|
const { check_id } = await startRes.json()
|
||||||
|
if (!check_id) throw new Error('Keine Check-ID erhalten')
|
||||||
|
|
||||||
|
// Poll for results
|
||||||
|
let attempts = 0
|
||||||
|
while (attempts < 120) {
|
||||||
|
await new Promise(r => setTimeout(r, 3000))
|
||||||
|
const pollRes = await fetch(`/api/sdk/v1/agent/doc-check?check_id=${check_id}`)
|
||||||
|
if (!pollRes.ok) { attempts++; continue }
|
||||||
|
const pollData = await pollRes.json()
|
||||||
|
if (pollData.progress) setProgress(pollData.progress)
|
||||||
|
if (pollData.status === 'completed' && pollData.result) {
|
||||||
|
setResults(pollData.result)
|
||||||
|
setProgress('')
|
||||||
|
localStorage.setItem('doc-check-results', JSON.stringify(pollData.result))
|
||||||
|
const entry = { date: new Date().toISOString(), urls: validEntries.length, findings: pollData.result.total_findings || 0 }
|
||||||
|
const updated = [entry, ...history].slice(0, 30)
|
||||||
|
setHistory(updated)
|
||||||
|
localStorage.setItem('doc-check-history', JSON.stringify(updated))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (pollData.status === 'failed') {
|
||||||
|
throw new Error(pollData.error || 'Pruefung fehlgeschlagen')
|
||||||
|
}
|
||||||
|
attempts++
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Unbekannter Fehler')
|
||||||
|
setProgress('')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* URL Entries */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{entries.map((entry, i) => (
|
||||||
|
<div key={entry.id} className="flex items-center gap-2">
|
||||||
|
<select
|
||||||
|
value={entry.type}
|
||||||
|
onChange={e => updateEntry(entry.id, 'type', e.target.value)}
|
||||||
|
className="w-48 px-3 py-2.5 border border-gray-300 rounded-lg text-sm bg-white shrink-0"
|
||||||
|
>
|
||||||
|
{DOC_TYPES.map(t => (
|
||||||
|
<option key={t.id} value={t.id}>{t.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={entry.label}
|
||||||
|
onChange={e => updateEntry(entry.id, 'label', e.target.value)}
|
||||||
|
placeholder={entry.type === 'other' ? 'Dokumentname' : 'Version / Stand (optional)'}
|
||||||
|
className="w-40 px-3 py-2.5 border border-gray-300 rounded-lg text-sm shrink-0"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={entry.url}
|
||||||
|
onChange={e => updateEntry(entry.id, 'url', e.target.value)}
|
||||||
|
onBlur={() => autoLabel(entry)}
|
||||||
|
placeholder="https://example.com/datenschutz"
|
||||||
|
className="flex-1 px-3 py-2.5 border border-gray-300 rounded-lg text-sm"
|
||||||
|
/>
|
||||||
|
{entries.length > 1 && (
|
||||||
|
<button onClick={() => removeEntry(entry.id)}
|
||||||
|
className="p-2 text-gray-400 hover:text-red-500 shrink-0">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* Add URL + Options */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<button onClick={addEntry}
|
||||||
|
className="flex items-center gap-1.5 text-sm text-purple-600 hover:text-purple-700 font-medium">
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
URL hinzufuegen
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 text-sm text-gray-600">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checkCookieBanner}
|
||||||
|
onChange={e => setCheckCookieBanner(e.target.checked)}
|
||||||
|
className="rounded border-gray-300 text-purple-600 focus:ring-purple-500"
|
||||||
|
/>
|
||||||
|
Cookie-Banner pruefen
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Submit */}
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={loading || entries.every(e => !e.url.trim())}
|
||||||
|
className="w-full px-4 py-3 bg-purple-600 text-white rounded-lg font-medium hover:bg-purple-700 disabled:opacity-50 transition-colors text-sm flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||||
|
</svg>
|
||||||
|
Pruefe...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
`${entries.filter(e => e.url.trim()).length} Dokument${entries.filter(e => e.url.trim()).length !== 1 ? 'e' : ''} pruefen`
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Progress */}
|
||||||
|
{progress && (
|
||||||
|
<div className="bg-purple-50 border border-purple-200 rounded-lg p-3 text-sm text-purple-700 flex items-center gap-3">
|
||||||
|
<svg className="animate-spin w-4 h-4 text-purple-500 shrink-0" 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 12h4z" />
|
||||||
|
</svg>
|
||||||
|
{progress}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-700">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
{results && results.results && (
|
||||||
|
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm">
|
||||||
|
<ChecklistView results={results.results} />
|
||||||
|
|
||||||
|
{/* Cookie Banner Result */}
|
||||||
|
{results.cookie_banner_result && (
|
||||||
|
<div className="mt-4 pt-4 border-t border-gray-200">
|
||||||
|
<h4 className="text-sm font-semibold text-gray-800 mb-2">Cookie-Banner</h4>
|
||||||
|
<div className="text-sm text-gray-600">
|
||||||
|
{results.cookie_banner_result.banner_detected
|
||||||
|
? `Banner erkannt: ${results.cookie_banner_result.banner_provider || 'unbekannt'}`
|
||||||
|
: 'Kein Banner erkannt'}
|
||||||
|
</div>
|
||||||
|
{results.cookie_banner_result.banner_checks?.violations?.length > 0 && (
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
{results.cookie_banner_result.banner_checks.violations.map((v: any, i: number) => (
|
||||||
|
<div key={i} className="text-xs text-red-600 flex items-start gap-1.5">
|
||||||
|
<span className="shrink-0 mt-0.5">!!</span>
|
||||||
|
<span>{v.text}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Email Status */}
|
||||||
|
{results.email_status && (
|
||||||
|
<div className="mt-3 text-xs text-gray-500 flex items-center gap-2">
|
||||||
|
<span className={`w-2 h-2 rounded-full ${results.email_status === 'sent' ? 'bg-green-400' : 'bg-gray-300'}`} />
|
||||||
|
E-Mail: {results.email_status === 'sent' ? 'Gesendet' : results.email_status}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* History */}
|
||||||
|
{history.length > 0 && (
|
||||||
|
<div className="border border-gray-200 rounded-xl p-4">
|
||||||
|
<h4 className="text-sm font-medium text-gray-700 mb-2">Letzte Pruefungen</h4>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{history.map((h, i) => (
|
||||||
|
<div key={i} className="flex items-center justify-between text-sm py-1.5 border-b border-gray-50 last:border-0">
|
||||||
|
<span className="text-gray-600">
|
||||||
|
{new Date(h.date).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-xs text-gray-500">{h.urls} Dok.</span>
|
||||||
|
<span className={`text-xs font-medium ${h.findings > 0 ? 'text-amber-600' : 'text-green-600'}`}>
|
||||||
|
{h.findings} Findings
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
import type { FollowUpQuestion } from '../_hooks/useAgentAnalysis'
|
||||||
|
|
||||||
|
const SEVERITY_STYLE: Record<string, { border: string; bg: string; icon: string }> = {
|
||||||
|
high: { border: 'border-red-300', bg: 'bg-red-50', icon: '!!' },
|
||||||
|
medium: { border: 'border-yellow-300', bg: 'bg-yellow-50', icon: '!' },
|
||||||
|
low: { border: 'border-blue-300', bg: 'bg-blue-50', icon: 'i' },
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
questions: FollowUpQuestion[]
|
||||||
|
answers: Record<string, boolean>
|
||||||
|
onAnswer: (questionId: string, answer: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FollowUpQuestions({ questions, answers, onAnswer }: Props) {
|
||||||
|
const unanswered = questions.filter(q => answers[q.id] === undefined)
|
||||||
|
const answered = questions.filter(q => answers[q.id] !== undefined)
|
||||||
|
|
||||||
|
if (questions.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="text-sm font-medium text-gray-700 flex items-center gap-2">
|
||||||
|
<svg className="w-4 h-4 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
Rueckfragen zur manuellen Pruefung ({unanswered.length} offen)
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
{/* Unanswered questions */}
|
||||||
|
{unanswered.map(q => {
|
||||||
|
const style = SEVERITY_STYLE[q.severity] || SEVERITY_STYLE.medium
|
||||||
|
return (
|
||||||
|
<div key={q.id} className={`border ${style.border} ${style.bg} rounded-lg p-4`}>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<span className={`mt-0.5 w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold ${
|
||||||
|
q.severity === 'high' ? 'bg-red-200 text-red-800' :
|
||||||
|
q.severity === 'medium' ? 'bg-yellow-200 text-yellow-800' :
|
||||||
|
'bg-blue-200 text-blue-800'
|
||||||
|
}`}>
|
||||||
|
{SEVERITY_STYLE[q.severity]?.icon || '?'}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium text-gray-900">{q.question}</p>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">Rechtsgrundlage: {q.legal_basis}</p>
|
||||||
|
<div className="flex gap-2 mt-3">
|
||||||
|
<button
|
||||||
|
onClick={() => onAnswer(q.id, true)}
|
||||||
|
className="px-4 py-1.5 text-sm bg-green-600 text-white rounded-md hover:bg-green-700 transition-colors"
|
||||||
|
>
|
||||||
|
Ja
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onAnswer(q.id, false)}
|
||||||
|
className="px-4 py-1.5 text-sm bg-red-600 text-white rounded-md hover:bg-red-700 transition-colors"
|
||||||
|
>
|
||||||
|
Nein
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Answered questions */}
|
||||||
|
{answered.map(q => {
|
||||||
|
const isYes = answers[q.id]
|
||||||
|
return (
|
||||||
|
<div key={q.id} className={`border rounded-lg p-3 ${isYes ? 'border-green-200 bg-green-50' : 'border-red-200 bg-red-50'}`}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`text-sm ${isYes ? 'text-green-700' : 'text-red-700'}`}>
|
||||||
|
{isYes ? '✓' : '✗'}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-gray-700">{q.question}</span>
|
||||||
|
<span className={`ml-auto text-xs font-medium ${isYes ? 'text-green-600' : 'text-red-600'}`}>
|
||||||
|
{isYes ? 'Ja — OK' : 'Nein — Finding erstellt'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{!isYes && (
|
||||||
|
<p className="text-xs text-red-600 mt-1 ml-6">{q.finding_if_no}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
|
||||||
|
interface ServiceInfo {
|
||||||
|
name: string
|
||||||
|
category: string
|
||||||
|
provider: string
|
||||||
|
country: string
|
||||||
|
eu_adequate: boolean
|
||||||
|
requires_consent: boolean
|
||||||
|
legal_ref: string
|
||||||
|
in_dse: boolean
|
||||||
|
status: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ScanFinding {
|
||||||
|
code: string
|
||||||
|
severity: string
|
||||||
|
text: string
|
||||||
|
correction: string
|
||||||
|
doc_title: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DiscoveredDocument {
|
||||||
|
title: string
|
||||||
|
url: string
|
||||||
|
doc_type: string
|
||||||
|
language: string
|
||||||
|
word_count: number
|
||||||
|
completeness_pct: number
|
||||||
|
findings_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ScanData {
|
||||||
|
pages_scanned: number
|
||||||
|
pages_list: string[]
|
||||||
|
services: ServiceInfo[]
|
||||||
|
findings: ScanFinding[]
|
||||||
|
discovered_documents?: DiscoveredDocument[]
|
||||||
|
ai_detected: boolean
|
||||||
|
chatbot_detected: boolean
|
||||||
|
chatbot_provider: string
|
||||||
|
missing_pages: Record<string, number>
|
||||||
|
email_status: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_ICON: Record<string, { icon: string; color: string }> = {
|
||||||
|
ok: { icon: '\u2713', color: 'text-green-600' },
|
||||||
|
undocumented: { icon: '\u2717', color: 'text-red-600' },
|
||||||
|
outdated: { icon: '~', color: 'text-yellow-600' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEV_STYLE: Record<string, { bg: string; text: string; dot: string }> = {
|
||||||
|
HIGH: { bg: 'bg-red-50 border-red-200', text: 'text-red-800', dot: 'bg-red-500' },
|
||||||
|
MEDIUM: { bg: 'bg-yellow-50 border-yellow-200', text: 'text-yellow-800', dot: 'bg-yellow-500' },
|
||||||
|
LOW: { bg: 'bg-blue-50 border-blue-200', text: 'text-blue-800', dot: 'bg-blue-500' },
|
||||||
|
CRITICAL: { bg: 'bg-red-100 border-red-300', text: 'text-red-900', dot: 'bg-red-700' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScanResult({ data }: { data: ScanData }) {
|
||||||
|
const [expandedCorrection, setExpandedCorrection] = useState<string | null>(null)
|
||||||
|
const [expandedDoc, setExpandedDoc] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const undocCount = data.services.filter(s => s.status === 'undocumented').length
|
||||||
|
const okCount = data.services.filter(s => s.status === 'ok').length
|
||||||
|
const highCount = data.findings.filter(f => f.severity === 'HIGH' || f.severity === 'CRITICAL').length
|
||||||
|
const docs = data.discovered_documents || []
|
||||||
|
|
||||||
|
// Group findings by doc_title
|
||||||
|
const docFindings: Record<string, ScanFinding[]> = {}
|
||||||
|
const generalFindings: ScanFinding[] = []
|
||||||
|
for (const f of data.findings) {
|
||||||
|
if (f.doc_title) {
|
||||||
|
if (!docFindings[f.doc_title]) docFindings[f.doc_title] = []
|
||||||
|
docFindings[f.doc_title].push(f)
|
||||||
|
} else {
|
||||||
|
generalFindings.push(f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{/* Summary Bar */}
|
||||||
|
<div className="grid grid-cols-4 gap-3">
|
||||||
|
<div className="bg-gray-50 rounded-lg p-3 text-center">
|
||||||
|
<p className="text-2xl font-bold text-gray-900">{data.pages_scanned}</p>
|
||||||
|
<p className="text-xs text-gray-500">Seiten</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-green-50 rounded-lg p-3 text-center">
|
||||||
|
<p className="text-2xl font-bold text-green-700">{okCount}</p>
|
||||||
|
<p className="text-xs text-gray-500">Dokumentiert</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-red-50 rounded-lg p-3 text-center">
|
||||||
|
<p className="text-2xl font-bold text-red-700">{undocCount}</p>
|
||||||
|
<p className="text-xs text-gray-500">Nicht in DSE</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-purple-50 rounded-lg p-3 text-center">
|
||||||
|
<p className="text-2xl font-bold text-purple-700">{docs.length}</p>
|
||||||
|
<p className="text-xs text-gray-500">Dokumente</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scanned Pages */}
|
||||||
|
{data.pages_list?.length > 0 && (
|
||||||
|
<details className="text-sm">
|
||||||
|
<summary className="text-gray-600 cursor-pointer hover:text-gray-800">
|
||||||
|
{data.pages_scanned} Seiten gescannt
|
||||||
|
</summary>
|
||||||
|
<ul className="mt-2 space-y-1 ml-4">
|
||||||
|
{data.pages_list.map((p, i) => {
|
||||||
|
const isMissing = data.missing_pages[p]
|
||||||
|
return (
|
||||||
|
<li key={i} className={`text-xs ${isMissing ? 'text-red-600' : 'text-gray-500'}`}>
|
||||||
|
{isMissing ? '\u2717' : '\u2713'} {p}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Services Table */}
|
||||||
|
{data.services.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-gray-700 mb-2">Dienstleister (SOLL/IST)</h4>
|
||||||
|
<div className="border rounded-lg overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Status</th>
|
||||||
|
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Dienst</th>
|
||||||
|
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Land</th>
|
||||||
|
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">In DSE</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
{data.services.map((s, i) => {
|
||||||
|
const st = STATUS_ICON[s.status] || STATUS_ICON.ok
|
||||||
|
return (
|
||||||
|
<tr key={i} className={s.status === 'undocumented' ? 'bg-red-50' : ''}>
|
||||||
|
<td className={`px-3 py-2 font-bold ${st.color}`}>{st.icon}</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<span className="font-medium text-gray-900">{s.name}</span>
|
||||||
|
<span className="text-gray-400 text-xs ml-2">{s.provider}</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-gray-600">{s.country}</td>
|
||||||
|
<td className="px-3 py-2">{s.in_dse ? '\u2713' : <span className="text-red-600 font-medium">Nein</span>}</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* === Document-Centric View === */}
|
||||||
|
{docs.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Rechtliche Dokumente ({docs.length})
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{docs.map((doc, i) => {
|
||||||
|
const isExpanded = expandedDoc === doc.title
|
||||||
|
const findings = docFindings[doc.title] || []
|
||||||
|
const pct = doc.completeness_pct
|
||||||
|
const barColor = pct >= 80 ? 'bg-green-500' : pct >= 50 ? 'bg-yellow-500' : 'bg-red-500'
|
||||||
|
const statusLabel = pct >= 80 ? 'OK' : pct >= 50 ? 'Lueckenhaft' : 'Mangelhaft'
|
||||||
|
const statusColor = pct >= 80 ? 'text-green-700 bg-green-50' : pct >= 50 ? 'text-yellow-700 bg-yellow-50' : 'text-red-700 bg-red-50'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={i} className="border border-gray-200 rounded-lg overflow-hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => setExpandedDoc(isExpanded ? null : doc.title)}
|
||||||
|
className="w-full flex items-center justify-between px-4 py-3 bg-gray-50/50 hover:bg-gray-50 text-left"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||||
|
<svg className={`w-4 h-4 text-gray-400 transition-transform shrink-0 ${isExpanded ? 'rotate-90' : ''}`}
|
||||||
|
fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-sm font-medium text-gray-900 truncate">{doc.title}</div>
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
{doc.word_count} Woerter
|
||||||
|
{findings.length > 0 && <span className="text-red-600 ml-2">{findings.length} Maengel</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 shrink-0 ml-3">
|
||||||
|
{/* Completeness bar */}
|
||||||
|
<div className="w-20 h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||||
|
<div className={`h-full rounded-full ${barColor}`} style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className={`text-xs font-medium px-2 py-0.5 rounded ${statusColor}`}>
|
||||||
|
{pct}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="px-4 py-3 border-t border-gray-100 space-y-2">
|
||||||
|
{findings.length > 0 ? (
|
||||||
|
findings.map((f, fi) => {
|
||||||
|
const sev = SEV_STYLE[f.severity] || SEV_STYLE.MEDIUM
|
||||||
|
return (
|
||||||
|
<div key={fi} className="flex items-start gap-2 text-sm">
|
||||||
|
<span className={`w-2 h-2 rounded-full mt-1.5 shrink-0 ${sev.dot}`} />
|
||||||
|
<span className="text-gray-700">{f.text}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-green-600">Alle Pflichtangaben vorhanden.</p>
|
||||||
|
)}
|
||||||
|
{doc.url && (
|
||||||
|
<a href={doc.url} target="_blank" rel="noopener noreferrer"
|
||||||
|
className="text-xs text-purple-600 hover:underline mt-2 inline-block">
|
||||||
|
Dokument oeffnen
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* General Findings (not associated with a specific document) */}
|
||||||
|
{generalFindings.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Allgemeine Findings ({generalFindings.length})
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{generalFindings.map((f, i) => {
|
||||||
|
const sev = SEV_STYLE[f.severity] || SEV_STYLE.MEDIUM
|
||||||
|
const corrKey = `gen-${i}`
|
||||||
|
const isExp = expandedCorrection === corrKey
|
||||||
|
return (
|
||||||
|
<div key={i} className={`border rounded-lg p-3 ${sev.bg}`}>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<span className={`text-xs font-bold px-2 py-0.5 rounded ${sev.text} bg-white`}>
|
||||||
|
{f.severity}
|
||||||
|
</span>
|
||||||
|
<p className="text-sm text-gray-800 flex-1">{f.text}</p>
|
||||||
|
</div>
|
||||||
|
{f.correction && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<button onClick={() => setExpandedCorrection(isExp ? null : corrKey)}
|
||||||
|
className="text-xs text-purple-600 hover:text-purple-800 font-medium">
|
||||||
|
{isExp ? 'Korrektur ausblenden' : 'Korrekturvorschlag'}
|
||||||
|
</button>
|
||||||
|
{isExp && (
|
||||||
|
<div className="mt-2 bg-white border border-gray-200 rounded-lg p-3 relative">
|
||||||
|
<pre className="text-xs text-gray-700 whitespace-pre-wrap font-sans">{f.correction}</pre>
|
||||||
|
<button onClick={() => navigator.clipboard.writeText(f.correction)}
|
||||||
|
className="absolute top-2 right-2 text-xs bg-gray-100 hover:bg-gray-200 px-2 py-1 rounded">
|
||||||
|
Kopieren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Email Status */}
|
||||||
|
{data.email_status && (
|
||||||
|
<div className="text-xs text-gray-500 flex items-center gap-2">
|
||||||
|
<span className={`w-2 h-2 rounded-full ${data.email_status === 'sent' ? 'bg-green-400' : 'bg-gray-300'}`} />
|
||||||
|
E-Mail: {data.email_status === 'sent' ? 'Gesendet' : data.email_status}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
export interface FollowUpQuestion {
|
||||||
|
id: string
|
||||||
|
question: string
|
||||||
|
legal_basis: string
|
||||||
|
severity: 'high' | 'medium' | 'low'
|
||||||
|
finding_if_no: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalysisResult {
|
||||||
|
url: string
|
||||||
|
classification: string
|
||||||
|
risk_level: string
|
||||||
|
risk_score: number
|
||||||
|
escalation_level: string
|
||||||
|
responsible_role: string
|
||||||
|
findings: string[]
|
||||||
|
required_controls: string[]
|
||||||
|
summary: string
|
||||||
|
email_status: string
|
||||||
|
analyzed_at: string
|
||||||
|
follow_up_questions: FollowUpQuestion[]
|
||||||
|
follow_up_answers: Record<string, boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
const ESCALATION_ROLES: Record<string, string> = {
|
||||||
|
E0: 'Kein Handlungsbedarf',
|
||||||
|
E1: 'Teamleitung Datenschutz',
|
||||||
|
E2: 'Datenschutzbeauftragter (DSB)',
|
||||||
|
E3: 'DSB + Rechtsabteilung',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAgentAnalysis() {
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [result, setResult] = useState<AnalysisResult | null>(null)
|
||||||
|
const [history, setHistory] = useState<AnalysisResult[]>([])
|
||||||
|
|
||||||
|
async function analyze(url: string, mode: string = 'post_launch') {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
setResult(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fetchRes = await fetch('/api/sdk/v1/agent/analyze', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ url, mode }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!fetchRes.ok) {
|
||||||
|
throw new Error(`Analyse fehlgeschlagen: ${fetchRes.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await fetchRes.json()
|
||||||
|
const analysisResult: AnalysisResult = {
|
||||||
|
url,
|
||||||
|
classification: data.classification || 'unknown',
|
||||||
|
risk_level: data.risk_level || 'unknown',
|
||||||
|
risk_score: data.risk_score || 0,
|
||||||
|
escalation_level: data.escalation_level || 'E0',
|
||||||
|
responsible_role: ESCALATION_ROLES[data.escalation_level] || ESCALATION_ROLES.E0,
|
||||||
|
findings: data.findings || [],
|
||||||
|
required_controls: data.required_controls || [],
|
||||||
|
summary: data.summary || '',
|
||||||
|
email_status: data.email_status || 'pending',
|
||||||
|
analyzed_at: new Date().toISOString(),
|
||||||
|
follow_up_questions: data.follow_up_questions || [],
|
||||||
|
follow_up_answers: {},
|
||||||
|
}
|
||||||
|
|
||||||
|
setResult(analysisResult)
|
||||||
|
setHistory(prev => [analysisResult, ...prev].slice(0, 20))
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Unbekannter Fehler')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function answerFollowUp(questionId: string, answer: boolean) {
|
||||||
|
if (!result) return
|
||||||
|
const question = result.follow_up_questions.find(q => q.id === questionId)
|
||||||
|
const newAnswers = { ...result.follow_up_answers, [questionId]: answer }
|
||||||
|
const newFindings = [...result.findings]
|
||||||
|
|
||||||
|
// If user answered "no" → add the finding
|
||||||
|
if (!answer && question) {
|
||||||
|
newFindings.push(question.finding_if_no)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = {
|
||||||
|
...result,
|
||||||
|
findings: newFindings,
|
||||||
|
follow_up_answers: newAnswers,
|
||||||
|
}
|
||||||
|
setResult(updated)
|
||||||
|
// Update history too
|
||||||
|
setHistory(prev => prev.map(h => h.analyzed_at === result.analyzed_at ? updated : h))
|
||||||
|
}
|
||||||
|
|
||||||
|
return { analyze, answerFollowUp, loading, error, result, history }
|
||||||
|
}
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
import { useAgentAnalysis } from './_hooks/useAgentAnalysis'
|
||||||
|
import { AnalysisResult } from './_components/AnalysisResult'
|
||||||
|
import { AnalysisHistory } from './_components/AnalysisHistory'
|
||||||
|
import { FollowUpQuestions } from './_components/FollowUpQuestions'
|
||||||
|
import { ScanResult } from './_components/ScanResult'
|
||||||
|
import { DocCheckTab } from './_components/DocCheckTab'
|
||||||
|
|
||||||
|
type AnalysisMode = 'pre_launch' | 'post_launch'
|
||||||
|
type AnalysisTab = 'quick' | 'scan' | 'doc-check'
|
||||||
|
|
||||||
|
const MODES: { id: AnalysisMode; label: string; desc: string; icon: string }[] = [
|
||||||
|
{ id: 'pre_launch', label: 'Internes Dokument', desc: 'Vor Veroeffentlichung pruefen', icon: '📋' },
|
||||||
|
{ id: 'post_launch', label: 'Live-Website', desc: 'Bereits online analysieren', icon: '🌐' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const TABS: { id: AnalysisTab; label: string; desc: string }[] = [
|
||||||
|
{ id: 'quick', label: 'Schnellanalyse', desc: 'Einzelne Seite klassifizieren + bewerten' },
|
||||||
|
{ id: 'scan', label: 'Website-Scan', desc: 'Mehrere Seiten scannen + Dienstleister abgleichen' },
|
||||||
|
{ id: 'doc-check', label: 'Dokumenten-Pruefung', desc: 'Einzelne Dokumente gezielt pruefen' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function AgentPage() {
|
||||||
|
// Restore state from localStorage on mount
|
||||||
|
const [url, setUrl] = useState(() => typeof window !== 'undefined' ? localStorage.getItem('agent-scan-url') || '' : '')
|
||||||
|
const [mode, setMode] = useState<AnalysisMode>(() => (typeof window !== 'undefined' ? localStorage.getItem('agent-scan-mode') as AnalysisMode : null) || 'post_launch')
|
||||||
|
const [tab, setTab] = useState<AnalysisTab>(() => (typeof window !== 'undefined' ? localStorage.getItem('agent-scan-tab') as AnalysisTab : null) || 'quick')
|
||||||
|
const [scanLoading, setScanLoading] = useState(false)
|
||||||
|
const [scanError, setScanError] = useState<string | null>(null)
|
||||||
|
const [scanData, setScanData] = useState<any>(() => {
|
||||||
|
if (typeof window === 'undefined') return null
|
||||||
|
try { const s = localStorage.getItem('agent-scan-result'); return s ? JSON.parse(s) : null } catch { return null }
|
||||||
|
})
|
||||||
|
const [scanProgress, setScanProgress] = useState<string>('')
|
||||||
|
const [activeScanId, setActiveScanId] = useState<string>(() => typeof window !== 'undefined' ? localStorage.getItem('agent-scan-id') || '' : '')
|
||||||
|
const [scanHistory, setScanHistory] = useState<{ url: string; date: string; findings: number; docs: number }[]>(() => {
|
||||||
|
if (typeof window === 'undefined') return []
|
||||||
|
try { return JSON.parse(localStorage.getItem('agent-scan-history') || '[]') } catch { return [] }
|
||||||
|
})
|
||||||
|
const { analyze, answerFollowUp, loading, error, result, history } = useAgentAnalysis()
|
||||||
|
|
||||||
|
// Persist state to localStorage
|
||||||
|
React.useEffect(() => { localStorage.setItem('agent-scan-url', url) }, [url])
|
||||||
|
React.useEffect(() => { localStorage.setItem('agent-scan-mode', mode) }, [mode])
|
||||||
|
React.useEffect(() => { localStorage.setItem('agent-scan-tab', tab) }, [tab])
|
||||||
|
React.useEffect(() => { if (scanData?.services) localStorage.setItem('agent-scan-result', JSON.stringify(scanData)) }, [scanData])
|
||||||
|
|
||||||
|
// Resume polling if scan was in progress when page was left
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!activeScanId || scanData?.services) return
|
||||||
|
let cancelled = false
|
||||||
|
setScanLoading(true)
|
||||||
|
setScanProgress('Scan laeuft noch...')
|
||||||
|
const poll = async () => {
|
||||||
|
while (!cancelled) {
|
||||||
|
await new Promise(r => setTimeout(r, 5000))
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/sdk/v1/agent/scan?scan_id=${activeScanId}`)
|
||||||
|
if (!res.ok) continue
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.progress) setScanProgress(data.progress)
|
||||||
|
if (data.status === 'completed' && data.result) {
|
||||||
|
setScanData(data.result)
|
||||||
|
setScanProgress('')
|
||||||
|
setScanLoading(false)
|
||||||
|
localStorage.setItem('agent-scan-result', JSON.stringify(data.result))
|
||||||
|
localStorage.removeItem('agent-scan-id')
|
||||||
|
setActiveScanId('')
|
||||||
|
_addToHistory(data.result)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (data.status === 'failed') {
|
||||||
|
setScanError(data.error || 'Scan fehlgeschlagen')
|
||||||
|
setScanProgress('')
|
||||||
|
setScanLoading(false)
|
||||||
|
localStorage.removeItem('agent-scan-id')
|
||||||
|
setActiveScanId('')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (data.status === 'not_found') {
|
||||||
|
setScanProgress('')
|
||||||
|
setScanLoading(false)
|
||||||
|
localStorage.removeItem('agent-scan-id')
|
||||||
|
setActiveScanId('')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch { /* retry */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
poll()
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const _addToHistory = (result: any) => {
|
||||||
|
const entry = {
|
||||||
|
url: url || result.url || '',
|
||||||
|
date: new Date().toISOString(),
|
||||||
|
findings: result.findings?.length || 0,
|
||||||
|
docs: result.discovered_documents?.length || 0,
|
||||||
|
}
|
||||||
|
const updated = [entry, ...scanHistory].slice(0, 50)
|
||||||
|
setScanHistory(updated)
|
||||||
|
localStorage.setItem('agent-scan-history', JSON.stringify(updated))
|
||||||
|
}
|
||||||
|
|
||||||
|
const _loadFromHistory = (entry: { url: string }) => {
|
||||||
|
setUrl(entry.url)
|
||||||
|
setTab('scan')
|
||||||
|
// Load saved result if same URL
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem('agent-scan-result')
|
||||||
|
if (saved) {
|
||||||
|
const parsed = JSON.parse(saved)
|
||||||
|
if (parsed.url === entry.url) {
|
||||||
|
setScanData(parsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!url.trim()) return
|
||||||
|
|
||||||
|
if (tab === 'quick') {
|
||||||
|
analyze(url.trim(), mode)
|
||||||
|
} else {
|
||||||
|
setScanLoading(true)
|
||||||
|
setScanError(null)
|
||||||
|
setScanData(null)
|
||||||
|
setScanProgress('Scan wird gestartet...')
|
||||||
|
try {
|
||||||
|
// Step 1: Start async scan
|
||||||
|
const startRes = await fetch('/api/sdk/v1/agent/scan', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ url: url.trim(), mode }),
|
||||||
|
})
|
||||||
|
if (!startRes.ok) throw new Error(`Scan konnte nicht gestartet werden: ${startRes.status}`)
|
||||||
|
const { scan_id } = await startRes.json()
|
||||||
|
if (!scan_id) throw new Error('Keine Scan-ID erhalten')
|
||||||
|
setActiveScanId(scan_id)
|
||||||
|
localStorage.setItem('agent-scan-id', scan_id)
|
||||||
|
|
||||||
|
// Step 2: Poll for results
|
||||||
|
let attempts = 0
|
||||||
|
const maxAttempts = 120 // 10 min at 5s intervals
|
||||||
|
while (attempts < maxAttempts) {
|
||||||
|
await new Promise(r => setTimeout(r, 5000))
|
||||||
|
const pollRes = await fetch(`/api/sdk/v1/agent/scan?scan_id=${scan_id}`)
|
||||||
|
if (!pollRes.ok) { attempts++; continue }
|
||||||
|
const pollData = await pollRes.json()
|
||||||
|
|
||||||
|
if (pollData.progress) {
|
||||||
|
setScanProgress(pollData.progress)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pollData.status === 'completed' && pollData.result) {
|
||||||
|
setScanData(pollData.result)
|
||||||
|
setScanProgress('')
|
||||||
|
localStorage.setItem('agent-scan-result', JSON.stringify(pollData.result))
|
||||||
|
localStorage.removeItem('agent-scan-id')
|
||||||
|
setActiveScanId('')
|
||||||
|
_addToHistory(pollData.result)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (pollData.status === 'failed') {
|
||||||
|
throw new Error(pollData.error || 'Scan fehlgeschlagen')
|
||||||
|
}
|
||||||
|
attempts++
|
||||||
|
}
|
||||||
|
if (attempts >= maxAttempts) throw new Error('Scan-Timeout (10 Minuten)')
|
||||||
|
} catch (e) {
|
||||||
|
setScanError(e instanceof Error ? e.message : 'Unbekannter Fehler')
|
||||||
|
setScanProgress('')
|
||||||
|
} finally {
|
||||||
|
setScanLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLoading = tab === 'quick' ? loading : scanLoading
|
||||||
|
const currentError = tab === 'quick' ? error : scanError
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 max-w-4xl">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Compliance Agent</h1>
|
||||||
|
<p className="text-gray-500 mt-1">Analysiere Dokumente und Webseiten auf DSGVO-Konformitaet.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mode Selection */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{MODES.map(m => (
|
||||||
|
<button key={m.id} onClick={() => setMode(m.id)}
|
||||||
|
className={`p-3 rounded-xl border-2 text-left transition-all ${
|
||||||
|
mode === m.id ? 'border-purple-500 bg-purple-50' : 'border-gray-200 hover:border-gray-300'}`}>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-xl">{m.icon}</span>
|
||||||
|
<div>
|
||||||
|
<p className={`text-sm font-semibold ${mode === m.id ? 'text-purple-900' : 'text-gray-900'}`}>{m.label}</p>
|
||||||
|
<p className="text-xs text-gray-500">{m.desc}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab Selection */}
|
||||||
|
<div className="flex border-b border-gray-200">
|
||||||
|
{TABS.map(t => (
|
||||||
|
<button key={t.id} onClick={() => setTab(t.id)}
|
||||||
|
className={`px-4 py-2.5 text-sm font-medium border-b-2 transition-colors ${
|
||||||
|
tab === t.id
|
||||||
|
? 'border-purple-500 text-purple-700'
|
||||||
|
: 'border-transparent text-gray-500 hover:text-gray-700'}`}>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Doc Check Tab — own component */}
|
||||||
|
{tab === 'doc-check' && <DocCheckTab />}
|
||||||
|
|
||||||
|
{/* URL Input (quick + scan only) */}
|
||||||
|
{tab !== 'doc-check' && <form onSubmit={handleSubmit} className="flex gap-3">
|
||||||
|
<input type="url" value={url} onChange={e => setUrl(e.target.value)}
|
||||||
|
placeholder={tab === 'scan' ? 'https://www.example.com/' : 'https://example.com/datenschutz'}
|
||||||
|
className="flex-1 px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent text-sm"
|
||||||
|
disabled={isLoading} required />
|
||||||
|
<button type="submit" disabled={isLoading || !url.trim()}
|
||||||
|
className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 transition-colors flex items-center gap-2 text-sm font-medium">
|
||||||
|
{isLoading ? (
|
||||||
|
<><svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||||
|
</svg>{tab === 'scan' ? 'Scanne...' : 'Analysiere...'}</>
|
||||||
|
) : tab === 'scan' ? 'Website scannen' : 'Analysieren'}
|
||||||
|
</button>
|
||||||
|
</form>}
|
||||||
|
|
||||||
|
{/* Scan Progress */}
|
||||||
|
{scanProgress && tab === 'scan' && (
|
||||||
|
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4 text-sm text-purple-700 flex items-center gap-3">
|
||||||
|
<svg className="animate-spin w-5 h-5 text-purple-500 shrink-0" 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 12h4z" />
|
||||||
|
</svg>
|
||||||
|
{scanProgress}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{currentError && (
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-sm text-red-700">{currentError}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Quick Analysis Result */}
|
||||||
|
{tab === 'quick' && result && (
|
||||||
|
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm space-y-6">
|
||||||
|
<AnalysisResult result={result} />
|
||||||
|
{result.follow_up_questions.length > 0 && (
|
||||||
|
<div className="border-t pt-4">
|
||||||
|
<FollowUpQuestions questions={result.follow_up_questions} answers={result.follow_up_answers} onAnswer={answerFollowUp} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Scan Result — only render when we have a complete response with services */}
|
||||||
|
{tab === 'scan' && scanData && scanData.services && (
|
||||||
|
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm">
|
||||||
|
<ScanResult data={scanData} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* History (quick only) */}
|
||||||
|
{tab === 'quick' && (
|
||||||
|
<AnalysisHistory history={history} onSelect={r => { setUrl(r.url); analyze(r.url, mode) }} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Scan History */}
|
||||||
|
{tab === 'scan' && scanHistory.length > 0 && (
|
||||||
|
<div className="border border-gray-200 rounded-xl p-4">
|
||||||
|
<h4 className="text-sm font-medium text-gray-700 mb-3">Letzte Scans</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{scanHistory.map((h, i) => (
|
||||||
|
<button key={i} onClick={() => _loadFromHistory(h)}
|
||||||
|
className="w-full flex items-center justify-between p-3 rounded-lg border border-gray-100 hover:border-purple-200 hover:bg-purple-50/30 transition-all text-left">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-sm font-medium text-gray-900 truncate">{h.url}</div>
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
{new Date(h.date).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 shrink-0 ml-3">
|
||||||
|
{h.docs > 0 && <span className="text-xs text-purple-600">{h.docs} Dok.</span>}
|
||||||
|
<span className={`text-xs font-medium ${h.findings > 0 ? 'text-red-600' : 'text-green-600'}`}>
|
||||||
|
{h.findings} Findings
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import React from 'react'
|
|
||||||
import Link from 'next/link'
|
|
||||||
|
|
||||||
export default function AgentSessionsPage() {
|
|
||||||
return (
|
|
||||||
<div className="p-8 max-w-5xl">
|
|
||||||
<div className="flex items-center gap-4 mb-8">
|
|
||||||
<Link href="/sdk/agents" className="text-gray-400 hover:text-gray-600 transition-colors">
|
|
||||||
<svg className="w-6 h-6" 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>
|
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Agent-Sessions</h1>
|
|
||||||
<p className="text-gray-500 mt-1">Chat-Verlaeufe und Session-Management</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white border border-gray-200 rounded-xl p-12 text-center">
|
|
||||||
<svg className="w-20 h-20 text-gray-300 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
|
|
||||||
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>
|
|
||||||
<h2 className="text-xl font-medium text-gray-900 mb-2">Sessions-Tracking</h2>
|
|
||||||
<p className="text-gray-500 max-w-md mx-auto">
|
|
||||||
Das Session-Tracking fuer Compliance-Agenten wird in einer zukuenftigen Version implementiert.
|
|
||||||
Hier werden Chat-Verlaeufe, Antwortqualitaet und Nutzer-Feedback angezeigt.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import React from 'react'
|
|
||||||
import Link from 'next/link'
|
|
||||||
|
|
||||||
export default function AgentStatisticsPage() {
|
|
||||||
return (
|
|
||||||
<div className="p-8 max-w-5xl">
|
|
||||||
<div className="flex items-center gap-4 mb-8">
|
|
||||||
<Link href="/sdk/agents" className="text-gray-400 hover:text-gray-600 transition-colors">
|
|
||||||
<svg className="w-6 h-6" 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>
|
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Agent-Statistiken</h1>
|
|
||||||
<p className="text-gray-500 mt-1">Performance-Metriken und Nutzungsanalysen</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white border border-gray-200 rounded-xl p-12 text-center">
|
|
||||||
<svg className="w-20 h-20 text-gray-300 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
|
|
||||||
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
|
||||||
</svg>
|
|
||||||
<h2 className="text-xl font-medium text-gray-900 mb-2">Agent-Statistiken</h2>
|
|
||||||
<p className="text-gray-500 max-w-md mx-auto">
|
|
||||||
Detaillierte Statistiken wie Antwortzeiten, Erfolgsraten, haeufigste Themen und
|
|
||||||
RAG-Trefferquoten werden in einer zukuenftigen Version implementiert.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -8,9 +8,178 @@ import { LoadingSkeleton } from './_components/LoadingSkeleton'
|
|||||||
import { RiskPyramid } from './_components/RiskPyramid'
|
import { RiskPyramid } from './_components/RiskPyramid'
|
||||||
import { AddSystemForm } from './_components/AddSystemForm'
|
import { AddSystemForm } from './_components/AddSystemForm'
|
||||||
import { AISystemCard } from './_components/AISystemCard'
|
import { AISystemCard } from './_components/AISystemCard'
|
||||||
|
import DecisionTreeWizard from '@/components/sdk/ai-act/DecisionTreeWizard'
|
||||||
|
|
||||||
|
type TabId = 'overview' | 'decision-tree' | 'results'
|
||||||
|
|
||||||
|
// SAVED RESULTS TAB
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
interface SavedResult {
|
||||||
|
id: string
|
||||||
|
system_name: string
|
||||||
|
system_description?: string
|
||||||
|
high_risk_result: string
|
||||||
|
gpai_result: { gpai_category: string; is_systemic_risk: boolean }
|
||||||
|
combined_obligations: string[]
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function SavedResultsTab() {
|
||||||
|
const [results, setResults] = useState<SavedResult[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/sdk/v1/ucca/decision-tree/results')
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
setResults(data.results || [])
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
load()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
if (!confirm('Ergebnis wirklich löschen?')) return
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/sdk/v1/ucca/decision-tree/results/${id}`, { method: 'DELETE' })
|
||||||
|
if (res.ok) {
|
||||||
|
setResults(prev => prev.filter(r => r.id !== id))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const riskLabels: Record<string, string> = {
|
||||||
|
unacceptable: 'Unzulässig',
|
||||||
|
high_risk: 'Hochrisiko',
|
||||||
|
limited_risk: 'Begrenztes Risiko',
|
||||||
|
minimal_risk: 'Minimales Risiko',
|
||||||
|
not_applicable: 'Nicht anwendbar',
|
||||||
|
}
|
||||||
|
|
||||||
|
const riskColors: Record<string, string> = {
|
||||||
|
unacceptable: 'bg-red-100 text-red-700',
|
||||||
|
high_risk: 'bg-orange-100 text-orange-700',
|
||||||
|
limited_risk: 'bg-yellow-100 text-yellow-700',
|
||||||
|
minimal_risk: 'bg-green-100 text-green-700',
|
||||||
|
not_applicable: 'bg-gray-100 text-gray-500',
|
||||||
|
}
|
||||||
|
|
||||||
|
const gpaiLabels: Record<string, string> = {
|
||||||
|
none: 'Kein GPAI',
|
||||||
|
standard: 'GPAI Standard',
|
||||||
|
systemic: 'GPAI Systemisch',
|
||||||
|
}
|
||||||
|
|
||||||
|
const gpaiColors: Record<string, string> = {
|
||||||
|
none: 'bg-gray-100 text-gray-500',
|
||||||
|
standard: 'bg-blue-100 text-blue-700',
|
||||||
|
systemic: 'bg-purple-100 text-purple-700',
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <LoadingSkeleton />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (results.length === 0) {
|
||||||
|
return (
|
||||||
|
<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">Keine Ergebnisse vorhanden</h3>
|
||||||
|
<p className="mt-2 text-gray-500">Nutzen Sie den Entscheidungsbaum, um KI-Systeme zu klassifizieren.</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{results.map(r => (
|
||||||
|
<div key={r.id} className="bg-white rounded-xl border border-gray-200 p-5">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold text-gray-900">{r.system_name}</h4>
|
||||||
|
{r.system_description && (
|
||||||
|
<p className="text-sm text-gray-500 mt-0.5">{r.system_description}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
<span className={`px-2 py-1 text-xs rounded-full ${riskColors[r.high_risk_result] || 'bg-gray-100 text-gray-500'}`}>
|
||||||
|
{riskLabels[r.high_risk_result] || r.high_risk_result}
|
||||||
|
</span>
|
||||||
|
<span className={`px-2 py-1 text-xs rounded-full ${gpaiColors[r.gpai_result?.gpai_category] || 'bg-gray-100 text-gray-500'}`}>
|
||||||
|
{gpaiLabels[r.gpai_result?.gpai_category] || 'Kein GPAI'}
|
||||||
|
</span>
|
||||||
|
{r.gpai_result?.is_systemic_risk && (
|
||||||
|
<span className="px-2 py-1 text-xs rounded-full bg-red-100 text-red-700">Systemisch</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400 mt-2">
|
||||||
|
{r.combined_obligations?.length || 0} Pflichten · {new Date(r.created_at).toLocaleDateString('de-DE')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(r.id)}
|
||||||
|
className="px-3 py-1 text-xs text-red-600 hover:bg-red-50 rounded transition-colors"
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TABS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
const TABS: { id: TabId; label: string; icon: React.ReactNode }[] = [
|
||||||
|
{
|
||||||
|
id: 'overview',
|
||||||
|
label: 'Übersicht',
|
||||||
|
icon: (
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'decision-tree',
|
||||||
|
label: 'Entscheidungsbaum',
|
||||||
|
icon: (
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 12h16.5m-16.5 3.75h16.5M3.75 19.5h16.5M5.625 4.5h12.75a1.875 1.875 0 010 3.75H5.625a1.875 1.875 0 010-3.75z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'results',
|
||||||
|
label: 'Ergebnisse',
|
||||||
|
icon: (
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// MAIN PAGE
|
||||||
|
|
||||||
export default function AIActPage() {
|
export default function AIActPage() {
|
||||||
const { state } = useSDK()
|
const { state } = useSDK()
|
||||||
|
const [activeTab, setActiveTab] = useState<TabId>('overview')
|
||||||
const [systems, setSystems] = useState<AISystem[]>([])
|
const [systems, setSystems] = useState<AISystem[]>([])
|
||||||
const [filter, setFilter] = useState<string>('all')
|
const [filter, setFilter] = useState<string>('all')
|
||||||
const [showAddForm, setShowAddForm] = useState(false)
|
const [showAddForm, setShowAddForm] = useState(false)
|
||||||
@@ -178,17 +347,38 @@ export default function AIActPage() {
|
|||||||
explanation={stepInfo.explanation}
|
explanation={stepInfo.explanation}
|
||||||
tips={stepInfo.tips}
|
tips={stepInfo.tips}
|
||||||
>
|
>
|
||||||
<button
|
{activeTab === 'overview' && (
|
||||||
onClick={() => setShowAddForm(true)}
|
<button
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
onClick={() => setShowAddForm(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 className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
</svg>
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||||
KI-System registrieren
|
</svg>
|
||||||
</button>
|
KI-System registrieren
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</StepHeader>
|
</StepHeader>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex items-center gap-1 bg-gray-100 p-1 rounded-lg w-fit">
|
||||||
|
{TABS.map(tab => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => setActiveTab(tab.id)}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-md transition-colors ${
|
||||||
|
activeTab === tab.id
|
||||||
|
? 'bg-white text-purple-700 shadow-sm'
|
||||||
|
: 'text-gray-600 hover:text-gray-900'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.icon}
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Banner */}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center justify-between">
|
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center justify-between">
|
||||||
<span>{error}</span>
|
<span>{error}</span>
|
||||||
@@ -196,82 +386,105 @@ export default function AIActPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showAddForm && (
|
{/* Tab: Overview */}
|
||||||
<AddSystemForm
|
{activeTab === 'overview' && (
|
||||||
onSubmit={handleAddSystem}
|
<>
|
||||||
onCancel={() => { setShowAddForm(false); setEditingSystem(null) }}
|
{/* Add/Edit System Form */}
|
||||||
initialData={editingSystem}
|
{showAddForm && (
|
||||||
/>
|
<AddSystemForm
|
||||||
)}
|
onSubmit={handleAddSystem}
|
||||||
|
onCancel={() => { setShowAddForm(false); setEditingSystem(null) }}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
initialData={editingSystem}
|
||||||
<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>
|
|
||||||
|
|
||||||
<RiskPyramid systems={systems} />
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
{loading && <LoadingSkeleton />}
|
|
||||||
|
|
||||||
{!loading && (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
{filteredSystems.map(system => (
|
|
||||||
<AISystemCard
|
|
||||||
key={system.id}
|
|
||||||
system={system}
|
|
||||||
onAssess={() => handleAssess(system.id)}
|
|
||||||
onEdit={() => handleEdit(system)}
|
|
||||||
onDelete={() => handleDelete(system.id)}
|
|
||||||
assessing={assessingId === system.id}
|
|
||||||
/>
|
/>
|
||||||
))}
|
)}
|
||||||
</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">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>
|
||||||
|
|
||||||
|
{/* Loading */}
|
||||||
|
{loading && <LoadingSkeleton />}
|
||||||
|
|
||||||
|
{/* AI Systems List */}
|
||||||
|
{!loading && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
{filteredSystems.map(system => (
|
||||||
|
<AISystemCard
|
||||||
|
key={system.id}
|
||||||
|
system={system}
|
||||||
|
onAssess={() => handleAssess(system.id)}
|
||||||
|
onEdit={() => handleEdit(system)}
|
||||||
|
onDelete={() => handleDelete(system.id)}
|
||||||
|
assessing={assessingId === system.id}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && filteredSystems.length === 0 && (
|
{/* Tab: Decision Tree */}
|
||||||
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
|
{activeTab === 'decision-tree' && (
|
||||||
<div className="w-16 h-16 mx-auto bg-purple-100 rounded-full flex items-center justify-center mb-4">
|
<DecisionTreeWizard />
|
||||||
<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>
|
{/* Tab: Results */}
|
||||||
</div>
|
{activeTab === 'results' && (
|
||||||
<h3 className="text-lg font-semibold text-gray-900">Keine KI-Systeme gefunden</h3>
|
<SavedResultsTab />
|
||||||
<p className="mt-2 text-gray-500">Passen Sie den Filter an oder registrieren Sie ein neues KI-System.</p>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,491 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
|
||||||
|
interface Registration {
|
||||||
|
id: string
|
||||||
|
system_name: string
|
||||||
|
system_version: string
|
||||||
|
risk_classification: string
|
||||||
|
gpai_classification: string
|
||||||
|
registration_status: string
|
||||||
|
eu_database_id: string
|
||||||
|
provider_name: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_STYLES: Record<string, { bg: string; text: string; label: string }> = {
|
||||||
|
draft: { bg: 'bg-gray-100', text: 'text-gray-700', label: 'Entwurf' },
|
||||||
|
ready: { bg: 'bg-blue-100', text: 'text-blue-700', label: 'Bereit' },
|
||||||
|
submitted: { bg: 'bg-yellow-100', text: 'text-yellow-700', label: 'Eingereicht' },
|
||||||
|
registered: { bg: 'bg-green-100', text: 'text-green-700', label: 'Registriert' },
|
||||||
|
update_required: { bg: 'bg-orange-100', text: 'text-orange-700', label: 'Update noetig' },
|
||||||
|
withdrawn: { bg: 'bg-red-100', text: 'text-red-700', label: 'Zurueckgezogen' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const RISK_STYLES: Record<string, { bg: string; text: string }> = {
|
||||||
|
high_risk: { bg: 'bg-red-100', text: 'text-red-700' },
|
||||||
|
limited_risk: { bg: 'bg-yellow-100', text: 'text-yellow-700' },
|
||||||
|
minimal_risk: { bg: 'bg-green-100', text: 'text-green-700' },
|
||||||
|
not_classified: { bg: 'bg-gray-100', text: 'text-gray-500' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const INITIAL_FORM = {
|
||||||
|
system_name: '',
|
||||||
|
system_version: '1.0',
|
||||||
|
system_description: '',
|
||||||
|
intended_purpose: '',
|
||||||
|
provider_name: '',
|
||||||
|
provider_legal_form: '',
|
||||||
|
provider_address: '',
|
||||||
|
provider_country: 'DE',
|
||||||
|
eu_representative_name: '',
|
||||||
|
eu_representative_contact: '',
|
||||||
|
risk_classification: 'not_classified',
|
||||||
|
annex_iii_category: '',
|
||||||
|
gpai_classification: 'none',
|
||||||
|
conformity_assessment_type: 'internal',
|
||||||
|
notified_body_name: '',
|
||||||
|
notified_body_id: '',
|
||||||
|
ce_marking: false,
|
||||||
|
training_data_summary: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AIRegistrationPage() {
|
||||||
|
const [registrations, setRegistrations] = useState<Registration[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [showWizard, setShowWizard] = useState(false)
|
||||||
|
const [wizardStep, setWizardStep] = useState(1)
|
||||||
|
const [form, setForm] = useState({ ...INITIAL_FORM })
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => { loadRegistrations() }, [])
|
||||||
|
|
||||||
|
async function loadRegistrations() {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
const resp = await fetch('/api/sdk/v1/ai-registration')
|
||||||
|
if (resp.ok) {
|
||||||
|
const data = await resp.json()
|
||||||
|
setRegistrations(data.registrations || [])
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError('Fehler beim Laden')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
setSubmitting(true)
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/sdk/v1/ai-registration', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(form),
|
||||||
|
})
|
||||||
|
if (resp.ok) {
|
||||||
|
setShowWizard(false)
|
||||||
|
setForm({ ...INITIAL_FORM })
|
||||||
|
setWizardStep(1)
|
||||||
|
loadRegistrations()
|
||||||
|
} else {
|
||||||
|
const data = await resp.json()
|
||||||
|
setError(data.error || 'Fehler beim Erstellen')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError('Netzwerkfehler')
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleExport(id: string) {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/sdk/v1/ai-registration/${id}`)
|
||||||
|
if (resp.ok) {
|
||||||
|
const reg = await resp.json()
|
||||||
|
// Build export JSON client-side
|
||||||
|
const exportData = {
|
||||||
|
schema_version: '1.0',
|
||||||
|
submission_type: 'ai_system_registration',
|
||||||
|
regulation: 'EU AI Act (EU) 2024/1689',
|
||||||
|
article: 'Art. 49',
|
||||||
|
provider: { name: reg.provider_name, address: reg.provider_address, country: reg.provider_country },
|
||||||
|
system: { name: reg.system_name, version: reg.system_version, description: reg.system_description, purpose: reg.intended_purpose },
|
||||||
|
classification: { risk_level: reg.risk_classification, annex_iii: reg.annex_iii_category, gpai: reg.gpai_classification },
|
||||||
|
conformity: { type: reg.conformity_assessment_type, ce_marking: reg.ce_marking },
|
||||||
|
}
|
||||||
|
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = `eu_ai_registration_${reg.system_name.replace(/\s+/g, '_')}.json`
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError('Export fehlgeschlagen')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStatusChange(id: string, status: string) {
|
||||||
|
try {
|
||||||
|
await fetch(`/api/sdk/v1/ai-registration/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ status }),
|
||||||
|
})
|
||||||
|
loadRegistrations()
|
||||||
|
} catch {
|
||||||
|
setError('Status-Aenderung fehlgeschlagen')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateForm = (updates: Partial<typeof form>) => setForm(prev => ({ ...prev, ...updates }))
|
||||||
|
|
||||||
|
const STEPS = [
|
||||||
|
{ id: 1, title: 'Anbieter', desc: 'Unternehmensangaben' },
|
||||||
|
{ id: 2, title: 'System', desc: 'KI-System Details' },
|
||||||
|
{ id: 3, title: 'Klassifikation', desc: 'Risikoeinstufung' },
|
||||||
|
{ id: 4, title: 'Konformitaet', desc: 'CE & Notified Body' },
|
||||||
|
{ id: 5, title: 'Trainingsdaten', desc: 'Datenzusammenfassung' },
|
||||||
|
{ id: 6, title: 'Pruefung', desc: 'Zusammenfassung & Export' },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-5xl mx-auto p-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">EU AI Database Registrierung</h1>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">Art. 49 KI-Verordnung (EU) 2024/1689 — Registrierung von Hochrisiko-KI-Systemen</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowWizard(true)}
|
||||||
|
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||||
|
>
|
||||||
|
+ Neue Registrierung
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
|
||||||
|
{error}
|
||||||
|
<button onClick={() => setError(null)} className="ml-2 underline">Schliessen</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-4 gap-4 mb-8">
|
||||||
|
{['draft', 'ready', 'submitted', 'registered'].map(status => {
|
||||||
|
const count = registrations.filter(r => r.registration_status === status).length
|
||||||
|
const style = STATUS_STYLES[status]
|
||||||
|
return (
|
||||||
|
<div key={status} className={`p-4 rounded-xl border ${style.bg}`}>
|
||||||
|
<div className={`text-2xl font-bold ${style.text}`}>{count}</div>
|
||||||
|
<div className="text-sm text-gray-600">{style.label}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Registrations List */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center py-12 text-gray-500">Lade...</div>
|
||||||
|
) : registrations.length === 0 ? (
|
||||||
|
<div className="text-center py-12 text-gray-400">
|
||||||
|
<p className="text-lg mb-2">Noch keine Registrierungen</p>
|
||||||
|
<p className="text-sm">Erstelle eine neue Registrierung fuer dein Hochrisiko-KI-System.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{registrations.map(reg => {
|
||||||
|
const status = STATUS_STYLES[reg.registration_status] || STATUS_STYLES.draft
|
||||||
|
const risk = RISK_STYLES[reg.risk_classification] || RISK_STYLES.not_classified
|
||||||
|
return (
|
||||||
|
<div key={reg.id} className="bg-white rounded-xl border border-gray-200 p-6 hover:border-purple-300 transition-all">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">{reg.system_name}</h3>
|
||||||
|
<span className="text-sm text-gray-400">v{reg.system_version}</span>
|
||||||
|
<span className={`px-2 py-0.5 text-xs rounded-full ${status.bg} ${status.text}`}>{status.label}</span>
|
||||||
|
<span className={`px-2 py-0.5 text-xs rounded-full ${risk.bg} ${risk.text}`}>{reg.risk_classification.replace('_', ' ')}</span>
|
||||||
|
{reg.gpai_classification !== 'none' && (
|
||||||
|
<span className="px-2 py-0.5 text-xs rounded-full bg-blue-100 text-blue-700">GPAI: {reg.gpai_classification}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
{reg.provider_name && <span>{reg.provider_name} · </span>}
|
||||||
|
{reg.eu_database_id && <span>EU-ID: {reg.eu_database_id} · </span>}
|
||||||
|
<span>{new Date(reg.created_at).toLocaleDateString('de-DE')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={() => handleExport(reg.id)} className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg hover:bg-gray-50">
|
||||||
|
JSON Export
|
||||||
|
</button>
|
||||||
|
{reg.registration_status === 'draft' && (
|
||||||
|
<button onClick={() => handleStatusChange(reg.id, 'ready')} className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700">
|
||||||
|
Bereit markieren
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{reg.registration_status === 'ready' && (
|
||||||
|
<button onClick={() => handleStatusChange(reg.id, 'submitted')} className="px-3 py-1.5 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700">
|
||||||
|
Als eingereicht markieren
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Wizard Modal */}
|
||||||
|
{showWizard && (
|
||||||
|
<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-y-auto">
|
||||||
|
<div className="p-6 border-b">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-xl font-bold text-gray-900">Neue EU AI Registrierung</h2>
|
||||||
|
<button onClick={() => { setShowWizard(false); setWizardStep(1) }} className="text-gray-400 hover:text-gray-600 text-2xl">×</button>
|
||||||
|
</div>
|
||||||
|
{/* Step Indicator */}
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{STEPS.map(step => (
|
||||||
|
<button key={step.id} onClick={() => setWizardStep(step.id)}
|
||||||
|
className={`flex-1 py-2 text-xs rounded-lg transition-all ${
|
||||||
|
wizardStep === step.id ? 'bg-purple-100 text-purple-700 font-medium' :
|
||||||
|
wizardStep > step.id ? 'bg-green-50 text-green-700' : 'bg-gray-50 text-gray-400'
|
||||||
|
}`}>
|
||||||
|
{wizardStep > step.id ? '✓ ' : ''}{step.title}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
{/* Step 1: Provider */}
|
||||||
|
{wizardStep === 1 && (
|
||||||
|
<>
|
||||||
|
<h3 className="font-semibold text-gray-900">Anbieter-Informationen</h3>
|
||||||
|
<p className="text-sm text-gray-500">Angaben zum Anbieter des KI-Systems gemaess Art. 49 KI-VO.</p>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Firmenname *</label>
|
||||||
|
<input value={form.provider_name} onChange={e => updateForm({ provider_name: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" placeholder="Acme GmbH" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Rechtsform</label>
|
||||||
|
<input value={form.provider_legal_form} onChange={e => updateForm({ provider_legal_form: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" placeholder="GmbH" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Adresse</label>
|
||||||
|
<input value={form.provider_address} onChange={e => updateForm({ provider_address: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" placeholder="Musterstr. 1, 20095 Hamburg" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Land</label>
|
||||||
|
<select value={form.provider_country} onChange={e => updateForm({ provider_country: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent">
|
||||||
|
<option value="DE">Deutschland</option>
|
||||||
|
<option value="AT">Oesterreich</option>
|
||||||
|
<option value="CH">Schweiz</option>
|
||||||
|
<option value="OTHER">Anderes Land</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">EU-Repraesentant (falls Non-EU)</label>
|
||||||
|
<input value={form.eu_representative_name} onChange={e => updateForm({ eu_representative_name: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" placeholder="Optional" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 2: System */}
|
||||||
|
{wizardStep === 2 && (
|
||||||
|
<>
|
||||||
|
<h3 className="font-semibold text-gray-900">KI-System Details</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Systemname *</label>
|
||||||
|
<input value={form.system_name} onChange={e => updateForm({ system_name: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" placeholder="z.B. HR Copilot" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Version</label>
|
||||||
|
<input value={form.system_version} onChange={e => updateForm({ system_version: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" placeholder="1.0" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Systembeschreibung</label>
|
||||||
|
<textarea value={form.system_description} onChange={e => updateForm({ system_description: e.target.value })} rows={3}
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" placeholder="Beschreibe was das KI-System tut..." />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Einsatzzweck (Intended Purpose)</label>
|
||||||
|
<textarea value={form.intended_purpose} onChange={e => updateForm({ intended_purpose: e.target.value })} rows={2}
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" placeholder="Wofuer wird das System eingesetzt?" />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 3: Classification */}
|
||||||
|
{wizardStep === 3 && (
|
||||||
|
<>
|
||||||
|
<h3 className="font-semibold text-gray-900">Risiko-Klassifikation</h3>
|
||||||
|
<p className="text-sm text-gray-500">Basierend auf dem AI Act Decision Tree oder manueller Einstufung.</p>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Risikoklasse</label>
|
||||||
|
<select value={form.risk_classification} onChange={e => updateForm({ risk_classification: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent">
|
||||||
|
<option value="not_classified">Noch nicht klassifiziert</option>
|
||||||
|
<option value="minimal_risk">Minimal Risk</option>
|
||||||
|
<option value="limited_risk">Limited Risk</option>
|
||||||
|
<option value="high_risk">High Risk</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{form.risk_classification === 'high_risk' && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Annex III Kategorie</label>
|
||||||
|
<select value={form.annex_iii_category} onChange={e => updateForm({ annex_iii_category: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent">
|
||||||
|
<option value="">Bitte waehlen...</option>
|
||||||
|
<option value="biometric">1. Biometrische Identifizierung</option>
|
||||||
|
<option value="critical_infrastructure">2. Kritische Infrastruktur</option>
|
||||||
|
<option value="education">3. Bildung und Berufsausbildung</option>
|
||||||
|
<option value="employment">4. Beschaeftigung und Arbeitnehmerverwaltung</option>
|
||||||
|
<option value="essential_services">5. Zugang zu wesentlichen Diensten</option>
|
||||||
|
<option value="law_enforcement">6. Strafverfolgung</option>
|
||||||
|
<option value="migration">7. Migration und Grenzkontrolle</option>
|
||||||
|
<option value="justice">8. Rechtspflege und Demokratie</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">GPAI Klassifikation</label>
|
||||||
|
<select value={form.gpai_classification} onChange={e => updateForm({ gpai_classification: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent">
|
||||||
|
<option value="none">Kein GPAI</option>
|
||||||
|
<option value="standard">GPAI (Standard)</option>
|
||||||
|
<option value="systemic">GPAI mit systemischem Risiko</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 text-sm text-blue-800">
|
||||||
|
<strong>Tipp:</strong> Nutze den <a href="/sdk/ai-act" className="underline">AI Act Decision Tree</a> fuer eine strukturierte Klassifikation.
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 4: Conformity */}
|
||||||
|
{wizardStep === 4 && (
|
||||||
|
<>
|
||||||
|
<h3 className="font-semibold text-gray-900">Konformitaetsbewertung</h3>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Art der Konformitaetsbewertung</label>
|
||||||
|
<select value={form.conformity_assessment_type} onChange={e => updateForm({ conformity_assessment_type: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent">
|
||||||
|
<option value="not_required">Nicht erforderlich</option>
|
||||||
|
<option value="internal">Interne Konformitaetsbewertung</option>
|
||||||
|
<option value="third_party">Drittpartei-Bewertung (Notified Body)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{form.conformity_assessment_type === 'third_party' && (
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Notified Body Name</label>
|
||||||
|
<input value={form.notified_body_name} onChange={e => updateForm({ notified_body_name: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border 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">Notified Body ID</label>
|
||||||
|
<input value={form.notified_body_id} onChange={e => updateForm({ notified_body_id: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<label className="flex items-center gap-3 p-3 rounded-lg border hover:bg-gray-50 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={form.ce_marking} onChange={e => updateForm({ ce_marking: e.target.checked })}
|
||||||
|
className="w-4 h-4 rounded border-gray-300 text-purple-600" />
|
||||||
|
<span className="text-sm font-medium text-gray-900">CE-Kennzeichnung angebracht</span>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 5: Training Data */}
|
||||||
|
{wizardStep === 5 && (
|
||||||
|
<>
|
||||||
|
<h3 className="font-semibold text-gray-900">Trainingsdaten-Zusammenfassung</h3>
|
||||||
|
<p className="text-sm text-gray-500">Art. 10 KI-VO — Keine vollstaendige Offenlegung, sondern Kategorien und Herkunft.</p>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Zusammenfassung der Trainingsdaten</label>
|
||||||
|
<textarea value={form.training_data_summary} onChange={e => updateForm({ training_data_summary: e.target.value })} rows={5}
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||||
|
placeholder="Beschreibe die verwendeten Datenquellen: - Oeffentliche Daten (z.B. Wikipedia, Common Crawl) - Lizenzierte Daten (z.B. Fachpublikationen) - Synthetische Daten - Unternehmensinterne Daten" />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 6: Review */}
|
||||||
|
{wizardStep === 6 && (
|
||||||
|
<>
|
||||||
|
<h3 className="font-semibold text-gray-900">Zusammenfassung</h3>
|
||||||
|
<div className="space-y-3 text-sm">
|
||||||
|
<div className="grid grid-cols-2 gap-4 p-4 bg-gray-50 rounded-lg">
|
||||||
|
<div><span className="text-gray-500">Anbieter:</span> <strong>{form.provider_name || '–'}</strong></div>
|
||||||
|
<div><span className="text-gray-500">Land:</span> <strong>{form.provider_country}</strong></div>
|
||||||
|
<div><span className="text-gray-500">System:</span> <strong>{form.system_name || '–'}</strong></div>
|
||||||
|
<div><span className="text-gray-500">Version:</span> <strong>{form.system_version}</strong></div>
|
||||||
|
<div><span className="text-gray-500">Risiko:</span> <strong>{form.risk_classification}</strong></div>
|
||||||
|
<div><span className="text-gray-500">GPAI:</span> <strong>{form.gpai_classification}</strong></div>
|
||||||
|
<div><span className="text-gray-500">Konformitaet:</span> <strong>{form.conformity_assessment_type}</strong></div>
|
||||||
|
<div><span className="text-gray-500">CE:</span> <strong>{form.ce_marking ? 'Ja' : 'Nein'}</strong></div>
|
||||||
|
</div>
|
||||||
|
{form.intended_purpose && (
|
||||||
|
<div className="p-4 bg-gray-50 rounded-lg">
|
||||||
|
<span className="text-gray-500">Zweck:</span> {form.intended_purpose}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 text-sm text-yellow-800">
|
||||||
|
<strong>Hinweis:</strong> Die EU AI Datenbank befindet sich noch im Aufbau. Die Registrierung wird lokal gespeichert und kann spaeter uebermittelt werden.
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<div className="p-6 border-t flex justify-between">
|
||||||
|
<button onClick={() => wizardStep > 1 ? setWizardStep(wizardStep - 1) : setShowWizard(false)}
|
||||||
|
className="px-4 py-2 text-gray-700 border rounded-lg hover:bg-gray-50">
|
||||||
|
{wizardStep === 1 ? 'Abbrechen' : 'Zurueck'}
|
||||||
|
</button>
|
||||||
|
{wizardStep < 6 ? (
|
||||||
|
<button onClick={() => setWizardStep(wizardStep + 1)}
|
||||||
|
disabled={wizardStep === 2 && !form.system_name}
|
||||||
|
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50">
|
||||||
|
Weiter
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button onClick={handleSubmit} disabled={submitting || !form.system_name}
|
||||||
|
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50">
|
||||||
|
{submitting ? 'Speichere...' : 'Registrierung erstellen'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -228,24 +228,39 @@ export const ARCH_SERVICES: ArchService[] = [
|
|||||||
dependsOn: ['qdrant', 'ollama', 'postgresql'],
|
dependsOn: ['qdrant', 'ollama', 'postgresql'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'document-crawler',
|
id: 'control-pipeline',
|
||||||
name: 'Document Crawler',
|
name: 'Control Pipeline',
|
||||||
nameShort: 'Crawler',
|
nameShort: 'Pipeline',
|
||||||
layer: 'backend',
|
layer: 'backend',
|
||||||
tech: 'Python / FastAPI',
|
tech: 'Python / FastAPI',
|
||||||
port: 8098,
|
port: 8098,
|
||||||
url: 'https://macmini:8098',
|
url: 'https://macmini:8098',
|
||||||
container: 'bp-compliance-document-crawler',
|
container: 'bp-core-control-pipeline',
|
||||||
description: 'Dokument-Analyse (PDF, DOCX, XLSX, PPTX), Gap-Analyse, IPFS-Archivierung.',
|
description: 'RAG-zu-Controls Pipeline: Control Generation, Pass 0a/0b, Ontology, Dedup, Dependency Engine, Applicability.',
|
||||||
descriptionLong: 'Der Document Crawler nimmt hochgeladene Dokumente (PDF, DOCX, XLSX, PPTX) entgegen, extrahiert deren Inhalt und fuehrt eine Gap-Analyse gegen bestehende Compliance-Anforderungen durch. Dafuer leitet er die Textinhalte an den AI Compliance SDK weiter, der die semantische Analyse uebernimmt. Abgeschlossene Dokumente koennen ueber den DSMS-Service dezentral auf IPFS archiviert werden.',
|
descriptionLong: 'Die Control Pipeline ist das Herzsttueck der automatisierten Compliance-Control-Generierung. Sie verarbeitet ~105.000 RAG-Chunks aus EU/DE-Regulierungen in 6 Phasen: (1) RAG Ingestion, (2) 7-Stufen Control Generation (Lizenz-Gate + Claude LLM), (3) Pass 0a Obligation Extraction (~181k Obligations), (4) Pass 0b Atomic Composition (MCP-taugliche Controls mit assertion/pass_criteria/fail_criteria), (5) Embedding-basierte Deduplizierung mit LLM-Verifikation, (6) Dependency Engine (5 Typen: supersedes, prerequisite, compensating_control, scope_exclusion, conditional_requirement) mit automatischer Generierung via Ontology, Pattern-Regeln und Domain Packs (DSGVO, AI Act, CRA, Security, Arbeitsrecht). 126+ Tests, alle bestanden.',
|
||||||
dbTables: [],
|
dbTables: [
|
||||||
ragCollections: [],
|
'canonical_controls', 'obligation_candidates', 'control_parent_links',
|
||||||
apiEndpoints: [
|
'control_dependencies', 'control_evaluation_results',
|
||||||
'POST /analyze',
|
'canonical_processed_chunks', 'canonical_generation_jobs',
|
||||||
'POST /gap-analysis',
|
'control_dedup_reviews', 'control_patterns',
|
||||||
'POST /archive',
|
|
||||||
],
|
],
|
||||||
dependsOn: ['ai-compliance-sdk', 'dsms'],
|
ragCollections: [
|
||||||
|
'bp_compliance_gesetze', 'bp_compliance_datenschutz',
|
||||||
|
'bp_compliance_ce', 'bp_dsfa_corpus', 'bp_legal_templates',
|
||||||
|
],
|
||||||
|
apiEndpoints: [
|
||||||
|
'POST /v1/canonical/generate',
|
||||||
|
'GET /v1/canonical/controls',
|
||||||
|
'POST /v1/canonical/controls/applicable',
|
||||||
|
'POST /v1/canonical/generate/submit-pass0b',
|
||||||
|
'POST /v1/canonical/generate/process-batch',
|
||||||
|
'GET /v1/canonical/generate/quality-metrics',
|
||||||
|
'POST /v1/dependencies/generate',
|
||||||
|
'POST /v1/dependencies/evaluate',
|
||||||
|
'GET /v1/dependencies/graph',
|
||||||
|
'POST /v1/document-compliance/required',
|
||||||
|
],
|
||||||
|
dependsOn: ['postgresql', 'qdrant', 'ollama'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'compliance-tts',
|
id: 'compliance-tts',
|
||||||
@@ -383,7 +398,7 @@ export const ARCH_EDGES: ArchEdge[] = [
|
|||||||
// Frontend → Backend
|
// Frontend → Backend
|
||||||
{ source: 'admin-compliance', target: 'backend-compliance', label: 'REST API' },
|
{ source: 'admin-compliance', target: 'backend-compliance', label: 'REST API' },
|
||||||
{ source: 'admin-compliance', target: 'ai-compliance-sdk', label: 'REST API' },
|
{ source: 'admin-compliance', target: 'ai-compliance-sdk', label: 'REST API' },
|
||||||
{ source: 'admin-compliance', target: 'document-crawler', label: 'REST API' },
|
{ source: 'admin-compliance', target: 'control-pipeline', label: 'REST API' },
|
||||||
|
|
||||||
// Backend → Infrastructure
|
// Backend → Infrastructure
|
||||||
{ source: 'backend-compliance', target: 'postgresql', label: 'SQLAlchemy' },
|
{ source: 'backend-compliance', target: 'postgresql', label: 'SQLAlchemy' },
|
||||||
@@ -392,12 +407,9 @@ export const ARCH_EDGES: ArchEdge[] = [
|
|||||||
{ source: 'ai-compliance-sdk', target: 'ollama', label: 'LLM Inference' },
|
{ source: 'ai-compliance-sdk', target: 'ollama', label: 'LLM Inference' },
|
||||||
{ source: 'ai-compliance-sdk', target: 'postgresql', label: 'GORM' },
|
{ source: 'ai-compliance-sdk', target: 'postgresql', label: 'GORM' },
|
||||||
{ source: 'compliance-tts', target: 'minio', label: 'Audio/Video' },
|
{ source: 'compliance-tts', target: 'minio', label: 'Audio/Video' },
|
||||||
|
{ source: 'control-pipeline', target: 'postgresql', label: 'SQLAlchemy' },
|
||||||
// Backend → Backend
|
{ source: 'control-pipeline', target: 'qdrant', label: 'Embedding + Dedup' },
|
||||||
{ source: 'document-crawler', target: 'ai-compliance-sdk', label: 'LLM Gateway' },
|
{ source: 'control-pipeline', target: 'ollama', label: 'LLM Dedup (qwen3.5)' },
|
||||||
|
|
||||||
// Backend → Data Sovereignty
|
|
||||||
{ source: 'document-crawler', target: 'dsms', label: 'IPFS Archive' },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|||||||
@@ -0,0 +1,263 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CMP Dashboard — Consent Management Platform overview.
|
||||||
|
*
|
||||||
|
* Aggregates data from: Banner API, Einwilligungen, DSR, Vendors.
|
||||||
|
* State-of-the-art layout inspired by OneTrust/Cookiebot dashboards
|
||||||
|
* but with EWR-Only as unique differentiator.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Use Next.js API proxy to avoid SSL cert issues
|
||||||
|
const BANNER_API = '/api/sdk/v1/banner'
|
||||||
|
const TENANT_ID = '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||||
|
const HEADERS = { 'x-tenant-id': TENANT_ID }
|
||||||
|
|
||||||
|
interface BannerStats { total_consents: number; category_acceptance: Record<string, { count: number; rate: number }> }
|
||||||
|
interface ConsentStats { total_consents: number; active_consents: number; revoked_consents: number; unique_users: number; conversion_rate: number }
|
||||||
|
interface DSRStats { total: number; by_status: Record<string, number>; by_type: Record<string, number>; overdue: number; due_this_week: number; average_processing_days: number; completed_this_month: number }
|
||||||
|
|
||||||
|
const MODULES = [
|
||||||
|
{ href: '/sdk/cookie-banner', label: 'Cookie-Banner', desc: 'Banner konfigurieren und Code exportieren', icon: 'shield', color: 'purple' },
|
||||||
|
{ href: '/sdk/cookie-banner/preview', label: 'Live-Vorschau', desc: 'Banner auf simulierter Website testen', icon: 'eye', color: 'blue' },
|
||||||
|
{ href: '/sdk/einwilligungen', label: 'Consent-Records', desc: 'Einwilligungen einsehen und verwalten', icon: 'clipboard', color: 'green' },
|
||||||
|
{ href: '/sdk/consent-management', label: 'Consent-Verwaltung', desc: 'Dokument-Lifecycle und DSGVO-Prozesse', icon: 'folder', color: 'indigo' },
|
||||||
|
{ href: '/sdk/vendor-compliance', label: 'Vendor-Compliance', desc: 'Dienstleister und Auftragsverarbeitung', icon: 'users', color: 'amber' },
|
||||||
|
{ href: '/sdk/dsr', label: 'DSR Portal', desc: 'Betroffenenrechte Art. 15-21 DSGVO', icon: 'user', color: 'rose' },
|
||||||
|
{ href: '/sdk/loeschfristen', label: 'Loeschfristen', desc: 'Aufbewahrungsrichtlinien verwalten', icon: 'clock', color: 'teal' },
|
||||||
|
{ href: '/sdk/email-templates', label: 'E-Mail-Templates', desc: 'Benachrichtigungsvorlagen', icon: 'mail', color: 'slate' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const ICON_MAP: Record<string, JSX.Element> = {
|
||||||
|
shield: <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" />,
|
||||||
|
eye: <><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></>,
|
||||||
|
clipboard: <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" />,
|
||||||
|
folder: <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />,
|
||||||
|
users: <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" />,
|
||||||
|
user: <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" />,
|
||||||
|
clock: <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />,
|
||||||
|
mail: <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />,
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLOR_MAP: Record<string, string> = {
|
||||||
|
purple: 'bg-purple-100 text-purple-600', blue: 'bg-blue-100 text-blue-600',
|
||||||
|
green: 'bg-green-100 text-green-600', indigo: 'bg-indigo-100 text-indigo-600',
|
||||||
|
amber: 'bg-amber-100 text-amber-600', rose: 'bg-rose-100 text-rose-600',
|
||||||
|
teal: 'bg-teal-100 text-teal-600', slate: 'bg-slate-100 text-slate-600',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CMPDashboardPage() {
|
||||||
|
const [bannerStats, setBannerStats] = useState<BannerStats | null>(null)
|
||||||
|
const [consentStats, setConsentStats] = useState<ConsentStats | null>(null)
|
||||||
|
const [dsrStats, setDSRStats] = useState<DSRStats | null>(null)
|
||||||
|
const [sites, setSites] = useState<any[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function load() {
|
||||||
|
const fb = (path: string) => fetch(`${BANNER_API}/${path}`, { headers: HEADERS }).then(r => r.ok ? r.json() : null).catch(() => null)
|
||||||
|
const fa = (path: string) => fetch(`/api/sdk/v1/compliance/${path}`, { headers: HEADERS }).then(r => r.ok ? r.json() : null).catch(() => null)
|
||||||
|
const [banner, consent, dsr, siteList] = await Promise.all([
|
||||||
|
fb('admin/stats/preview-test-site'),
|
||||||
|
fa('einwilligungen/consents/stats'),
|
||||||
|
fa('dsr/stats'),
|
||||||
|
fb('admin/sites'),
|
||||||
|
])
|
||||||
|
setBannerStats(banner)
|
||||||
|
setConsentStats(consent)
|
||||||
|
setDSRStats(dsr)
|
||||||
|
setSites(siteList || [])
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
load()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const totalConsents = (bannerStats?.total_consents || 0) + (consentStats?.total_consents || 0)
|
||||||
|
const dsrOpen = dsrStats ? (dsrStats.by_status?.intake || 0) + (dsrStats.by_status?.processing || 0) + (dsrStats.by_status?.identity_verification || 0) : 0
|
||||||
|
const dsrOverdue = dsrStats?.overdue || 0
|
||||||
|
const catAcceptance = bannerStats?.category_acceptance || {}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Consent Management Platform</h1>
|
||||||
|
<p className="text-gray-500 mt-1">Ueberblick ueber Einwilligungen, Betroffenenrechte und Vendor-Compliance</p>
|
||||||
|
</div>
|
||||||
|
<Link href="/sdk/cookie-banner/preview"
|
||||||
|
className="px-4 py-2 bg-purple-600 text-white rounded-lg text-sm font-medium hover:bg-purple-700 transition-colors">
|
||||||
|
Banner testen
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* KPI Cards */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<KPICard label="Consents gesamt" value={loading ? '...' : totalConsents} icon="shield" trend={null} />
|
||||||
|
<KPICard label="Aktive Einwilligungen" value={loading ? '...' : consentStats?.active_consents ?? 0} icon="check" trend={consentStats?.conversion_rate ? `${consentStats.conversion_rate.toFixed(0)}% Rate` : null} />
|
||||||
|
<KPICard label="Offene DSR-Anfragen" value={loading ? '...' : dsrOpen} icon="user" trend={dsrOverdue > 0 ? `${dsrOverdue} ueberfaellig` : null} trendColor={dsrOverdue > 0 ? 'red' : 'green'} />
|
||||||
|
<KPICard label="Konfigurierte Sites" value={loading ? '...' : sites.length} icon="globe" trend={null} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category Acceptance + DSR Breakdown */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* Cookie Category Acceptance */}
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-4">Cookie-Kategorie Akzeptanz</h3>
|
||||||
|
{Object.keys(catAcceptance).length > 0 ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{Object.entries(catAcceptance).map(([cat, data]) => (
|
||||||
|
<div key={cat} className="flex items-center gap-4">
|
||||||
|
<span className="text-sm text-gray-600 w-24 capitalize">{cat}</span>
|
||||||
|
<div className="flex-1 h-6 bg-gray-100 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full transition-all ${
|
||||||
|
cat === 'necessary' ? 'bg-gray-400' : cat === 'marketing' ? 'bg-rose-500' : cat === 'statistics' ? 'bg-blue-500' : 'bg-green-500'
|
||||||
|
}`}
|
||||||
|
style={{ width: `${data.rate}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium text-gray-700 w-16 text-right">{data.rate}%</span>
|
||||||
|
<span className="text-xs text-gray-400 w-12 text-right">{data.count}x</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8 text-gray-400">
|
||||||
|
<p className="text-sm">Noch keine Consent-Daten vorhanden</p>
|
||||||
|
<Link href="/sdk/cookie-banner/preview" className="text-purple-600 text-sm underline mt-2 inline-block">
|
||||||
|
Jetzt Banner testen
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* DSR Breakdown */}
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="font-semibold text-gray-900">Betroffenenrechte (DSR)</h3>
|
||||||
|
<Link href="/sdk/dsr" className="text-xs text-purple-600 hover:underline">Alle anzeigen</Link>
|
||||||
|
</div>
|
||||||
|
{dsrStats && dsrStats.total > 0 ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<MiniStat label="Gesamt" value={dsrStats.total} />
|
||||||
|
<MiniStat label="Abgeschlossen" value={dsrStats.by_status?.completed || 0} color="green" />
|
||||||
|
<MiniStat label="Ueberfaellig" value={dsrOverdue} color={dsrOverdue > 0 ? 'red' : 'gray'} />
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-gray-100 pt-3">
|
||||||
|
<div className="text-xs text-gray-500 mb-2">Nach Typ</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{Object.entries(dsrStats.by_type || {}).filter(([, v]) => v > 0).map(([type, count]) => (
|
||||||
|
<div key={type} className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-gray-600">{DSR_TYPE_LABELS[type] || type}</span>
|
||||||
|
<span className="font-medium text-gray-800">{count}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{dsrStats.average_processing_days > 0 && (
|
||||||
|
<div className="border-t border-gray-100 pt-3 flex items-center justify-between text-sm">
|
||||||
|
<span className="text-gray-500">Durchschnittl. Bearbeitungszeit</span>
|
||||||
|
<span className="font-medium text-gray-800">{dsrStats.average_processing_days.toFixed(1)} Tage</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8 text-gray-400 text-sm">
|
||||||
|
Keine DSR-Anfragen vorhanden
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Compliance Status */}
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-1">Compliance-Status</h3>
|
||||||
|
<p className="text-xs text-gray-500 mb-4">Pruefung der wichtigsten DSGVO-Anforderungen</p>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
|
<ComplianceCheck label="Cookie-Banner konfiguriert" ok={sites.length > 0} href="/sdk/cookie-banner" />
|
||||||
|
<ComplianceCheck label="Datenschutzerklaerung erstellt" ok={false} href="/sdk/einwilligungen/privacy-policy" />
|
||||||
|
<ComplianceCheck label="Impressum verlinkt" ok={false} href="/sdk/document-generator" />
|
||||||
|
<ComplianceCheck label="Consent-Nachweis (Art. 7)" ok={totalConsents > 0} href="/sdk/einwilligungen" />
|
||||||
|
<ComplianceCheck label="DSR-Prozess eingerichtet" ok={dsrStats?.total !== undefined} href="/sdk/dsr" />
|
||||||
|
<ComplianceCheck label="Loeschfristen definiert" ok={false} href="/sdk/loeschfristen" />
|
||||||
|
<ComplianceCheck label="Vendor-AVV vorhanden" ok={false} href="/sdk/vendor-compliance" />
|
||||||
|
<ComplianceCheck label="E-Mail-Templates aktiv" ok={false} href="/sdk/email-templates" />
|
||||||
|
<ComplianceCheck label="EWR-Only Modus verfuegbar" ok={true} href="/sdk/cookie-banner" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Module Grid */}
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-3">CMP Module</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||||
|
{MODULES.map(m => (
|
||||||
|
<Link key={m.href} href={m.href}
|
||||||
|
className="group bg-white border border-gray-200 rounded-xl p-4 hover:border-purple-300 hover:shadow-md transition-all">
|
||||||
|
<div className={`w-10 h-10 rounded-lg ${COLOR_MAP[m.color]} flex items-center justify-center mb-3`}>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
{ICON_MAP[m.icon]}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="font-medium text-gray-900 group-hover:text-purple-700 text-sm">{m.label}</div>
|
||||||
|
<div className="text-xs text-gray-500 mt-0.5">{m.desc}</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const DSR_TYPE_LABELS: Record<string, string> = {
|
||||||
|
access: 'Auskunft (Art. 15)', rectification: 'Berichtigung (Art. 16)',
|
||||||
|
erasure: 'Loeschung (Art. 17)', restriction: 'Einschraenkung (Art. 18)',
|
||||||
|
portability: 'Portabilitaet (Art. 20)', objection: 'Widerspruch (Art. 21)',
|
||||||
|
}
|
||||||
|
|
||||||
|
function KPICard({ label, value, icon, trend, trendColor }: {
|
||||||
|
label: string; value: number | string; icon: string; trend: string | null; trendColor?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-4">
|
||||||
|
<div className="text-xs text-gray-500">{label}</div>
|
||||||
|
<div className="text-2xl font-bold text-gray-900 mt-1">{value}</div>
|
||||||
|
{trend && (
|
||||||
|
<div className={`text-xs mt-1 ${trendColor === 'red' ? 'text-red-600' : trendColor === 'green' ? 'text-green-600' : 'text-gray-500'}`}>
|
||||||
|
{trend}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MiniStat({ label, value, color }: { label: string; value: number; color?: string }) {
|
||||||
|
const c = color === 'red' ? 'text-red-600' : color === 'green' ? 'text-green-600' : 'text-gray-900'
|
||||||
|
return (
|
||||||
|
<div className="text-center">
|
||||||
|
<div className={`text-xl font-bold ${c}`}>{value}</div>
|
||||||
|
<div className="text-xs text-gray-500">{label}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComplianceCheck({ label, ok, href }: { label: string; ok: boolean; href: string }) {
|
||||||
|
return (
|
||||||
|
<Link href={href} className="flex items-center gap-3 p-3 rounded-lg border border-gray-100 hover:border-purple-200 hover:bg-purple-50/30 transition-all">
|
||||||
|
{ok ? (
|
||||||
|
<svg className="w-5 h-5 text-green-500 shrink-0" 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>
|
||||||
|
) : (
|
||||||
|
<svg className="w-5 h-5 text-amber-400 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-sm text-gray-700">{label}</span>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { useParams } from 'next/navigation'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { ZoneBadge } from '@/components/sdk/compliance-optimizer/ZoneBadge'
|
||||||
|
import { DimensionZoneTable } from '@/components/sdk/compliance-optimizer/DimensionZoneTable'
|
||||||
|
import { ConfigComparison } from '@/components/sdk/compliance-optimizer/ConfigComparison'
|
||||||
|
import { OptimizationScoreCard } from '@/components/sdk/compliance-optimizer/OptimizationScoreCard'
|
||||||
|
|
||||||
|
export default function OptimizationDetailPage() {
|
||||||
|
const params = useParams()
|
||||||
|
const id = params?.id as string
|
||||||
|
const [data, setData] = useState<any>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [activeVariant, setActiveVariant] = useState(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!id) return
|
||||||
|
fetch(`/api/sdk/v1/maximizer/optimizations/${id}`)
|
||||||
|
.then((r) => r.ok ? r.json() : null)
|
||||||
|
.then(setData)
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [id])
|
||||||
|
|
||||||
|
if (loading) return <div className="max-w-6xl mx-auto p-6 text-gray-500">Laden...</div>
|
||||||
|
if (!data) return <div className="max-w-6xl mx-auto p-6 text-red-600">Optimierung nicht gefunden.</div>
|
||||||
|
|
||||||
|
const maxSafe = data.max_safe_config
|
||||||
|
const variants = data.variants || []
|
||||||
|
const zones = data.zone_map || {}
|
||||||
|
const controls = data.original_evaluation?.required_controls || []
|
||||||
|
const patterns = data.original_evaluation?.required_patterns || []
|
||||||
|
const triggered = data.original_evaluation?.triggered_rules || []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-6xl mx-auto p-6 space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Link href="/sdk/compliance-optimizer" className="text-sm text-blue-600 hover:underline">← Zurueck</Link>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 mt-1">{data.title || 'Optimierung'}</h1>
|
||||||
|
<p className="text-sm text-gray-500">{new Date(data.created_at).toLocaleString('de-DE')} — v{data.constraint_version}</p>
|
||||||
|
{data.assessment_id && (
|
||||||
|
<Link href={`/sdk/use-cases/${data.assessment_id}`} className="text-sm text-purple-600 hover:underline">
|
||||||
|
Basierend auf Assessment
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ZoneBadge zone={data.is_compliant ? 'SAFE' : 'FORBIDDEN'} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 3-Zone Summary */}
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-800 mb-3">3-Zonen-Analyse</h2>
|
||||||
|
<DimensionZoneTable zoneMap={zones} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Optimization Result */}
|
||||||
|
{maxSafe && (
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-800 mb-3">Optimierte Konfiguration</h2>
|
||||||
|
<OptimizationScoreCard
|
||||||
|
safetyScore={maxSafe.safety_score}
|
||||||
|
utilityScore={maxSafe.utility_score}
|
||||||
|
compositeScore={maxSafe.composite_score}
|
||||||
|
deltaCount={maxSafe.delta_count}
|
||||||
|
/>
|
||||||
|
<div className="mt-4">
|
||||||
|
<ConfigComparison deltas={maxSafe.deltas || []} />
|
||||||
|
</div>
|
||||||
|
{maxSafe.rationale && (
|
||||||
|
<p className="mt-3 text-sm text-gray-600 italic">{maxSafe.rationale}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Alternative Variants */}
|
||||||
|
{variants.length > 1 && (
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-800 mb-3">Alternative Varianten ({variants.length})</h2>
|
||||||
|
<div className="flex gap-2 mb-3">
|
||||||
|
{variants.map((v: any, i: number) => (
|
||||||
|
<button key={i} onClick={() => setActiveVariant(i)}
|
||||||
|
className={`px-3 py-1 text-sm rounded ${i === activeVariant ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}`}>
|
||||||
|
Variante {i + 1}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{variants[activeVariant] && (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-4 mb-2 text-sm text-gray-600">
|
||||||
|
<span>Sicherheit: {variants[activeVariant].safety_score}</span>
|
||||||
|
<span>Nutzen: {variants[activeVariant].utility_score}</span>
|
||||||
|
<span>Gesamt: {Math.round(variants[activeVariant].composite_score)}</span>
|
||||||
|
</div>
|
||||||
|
<ConfigComparison deltas={variants[activeVariant].deltas || []} />
|
||||||
|
{variants[activeVariant].rationale && (
|
||||||
|
<p className="mt-2 text-sm text-gray-500 italic">{variants[activeVariant].rationale}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Required Controls & Patterns */}
|
||||||
|
{(controls.length > 0 || patterns.length > 0) && (
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-800 mb-3">Erforderliche Massnahmen</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{controls.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-gray-700 mb-2">Controls</h4>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{controls.map((c: string, i: number) => (
|
||||||
|
<li key={i} className="text-sm text-gray-600 flex items-center gap-2">
|
||||||
|
<span className="w-1.5 h-1.5 bg-blue-500 rounded-full" />{c}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{patterns.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-gray-700 mb-2">Architektur-Patterns</h4>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{patterns.map((p: string, i: number) => (
|
||||||
|
<li key={i} className="text-sm text-gray-600 flex items-center gap-2">
|
||||||
|
<span className="w-1.5 h-1.5 bg-purple-500 rounded-full" />{p}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Triggered Rules (Audit Trail) */}
|
||||||
|
{triggered.length > 0 && (
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-800 mb-3">Ausgeloeste Regeln ({triggered.length})</h2>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{triggered.map((r: any, i: number) => (
|
||||||
|
<div key={i} className="flex items-start gap-3 text-sm border-b border-gray-100 pb-2">
|
||||||
|
<span className="font-mono text-xs text-gray-400 min-w-[120px]">{r.rule_id}</span>
|
||||||
|
<span className="text-gray-700">{r.title}</span>
|
||||||
|
<span className="text-gray-400 ml-auto text-xs">{r.article_ref}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useState, useEffect, Suspense } from 'react'
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation'
|
||||||
|
import { ZoneBadge } from '@/components/sdk/compliance-optimizer/ZoneBadge'
|
||||||
|
|
||||||
|
interface DimensionField {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
options: { value: string; label: string }[]
|
||||||
|
type?: 'select' | 'toggle'
|
||||||
|
}
|
||||||
|
|
||||||
|
const DIMENSIONS: DimensionField[] = [
|
||||||
|
{ key: 'automation_level', label: 'Automatisierungsgrad', options: [
|
||||||
|
{ value: 'none', label: 'Keine' }, { value: 'assistive', label: 'Assistierend' },
|
||||||
|
{ value: 'partial', label: 'Teilautomatisiert' }, { value: 'full', label: 'Vollautomatisiert' },
|
||||||
|
]},
|
||||||
|
{ key: 'decision_binding', label: 'Entscheidungsbindung', options: [
|
||||||
|
{ value: 'non_binding', label: 'Unverbindlich' }, { value: 'human_review_required', label: 'Mensch entscheidet' },
|
||||||
|
{ value: 'fully_binding', label: 'Vollstaendig bindend' },
|
||||||
|
]},
|
||||||
|
{ key: 'decision_impact', label: 'Entscheidungswirkung', options: [
|
||||||
|
{ value: 'low', label: 'Niedrig' }, { value: 'medium', label: 'Mittel' }, { value: 'high', label: 'Hoch' },
|
||||||
|
]},
|
||||||
|
{ key: 'domain', label: 'Branche', options: [
|
||||||
|
{ value: 'hr', label: 'HR / Personal' }, { value: 'finance', label: 'Finanzen' },
|
||||||
|
{ value: 'education', label: 'Bildung' }, { value: 'health', label: 'Gesundheit' },
|
||||||
|
{ value: 'marketing', label: 'Marketing' }, { value: 'general', label: 'Allgemein' },
|
||||||
|
]},
|
||||||
|
{ key: 'data_type', label: 'Datensensitivitaet', options: [
|
||||||
|
{ value: 'non_personal', label: 'Keine personenbezogenen' }, { value: 'personal', label: 'Personenbezogen' },
|
||||||
|
{ value: 'sensitive', label: 'Besondere Kategorien (Art. 9)' }, { value: 'biometric', label: 'Biometrisch' },
|
||||||
|
]},
|
||||||
|
{ key: 'human_in_loop', label: 'Menschliche Kontrolle', options: [
|
||||||
|
{ value: 'required', label: 'Erforderlich' }, { value: 'optional', label: 'Optional' }, { value: 'none', label: 'Keine' },
|
||||||
|
]},
|
||||||
|
{ key: 'explainability', label: 'Erklaerbarkeit', options: [
|
||||||
|
{ value: 'high', label: 'Hoch' }, { value: 'basic', label: 'Basis' }, { value: 'none', label: 'Keine' },
|
||||||
|
]},
|
||||||
|
{ key: 'risk_classification', label: 'Risikoklasse (AI Act)', options: [
|
||||||
|
{ value: 'minimal', label: 'Minimal' }, { value: 'limited', label: 'Begrenzt' },
|
||||||
|
{ value: 'high', label: 'Hoch' }, { value: 'prohibited', label: 'Verboten' },
|
||||||
|
]},
|
||||||
|
{ key: 'legal_basis', label: 'Rechtsgrundlage (DSGVO)', options: [
|
||||||
|
{ value: 'consent', label: 'Einwilligung' }, { value: 'contract', label: 'Vertrag' },
|
||||||
|
{ value: 'legal_obligation', label: 'Rechtl. Verpflichtung' },
|
||||||
|
{ value: 'legitimate_interest', label: 'Berechtigtes Interesse' },
|
||||||
|
{ value: 'public_interest', label: 'Oeffentl. Interesse' },
|
||||||
|
]},
|
||||||
|
{ key: 'model_type', label: 'Modelltyp', options: [
|
||||||
|
{ value: 'rule_based', label: 'Regelbasiert' }, { value: 'statistical', label: 'Statistisch / ML' },
|
||||||
|
{ value: 'blackbox_llm', label: 'Blackbox / LLM' },
|
||||||
|
]},
|
||||||
|
{ key: 'deployment_scope', label: 'Einsatzbereich', options: [
|
||||||
|
{ value: 'internal', label: 'Intern' }, { value: 'external', label: 'Extern (Kunden)' },
|
||||||
|
{ value: 'public', label: 'Oeffentlich' },
|
||||||
|
]},
|
||||||
|
]
|
||||||
|
|
||||||
|
const TOGGLE_DIMENSIONS = [
|
||||||
|
{ key: 'transparency_required', label: 'Transparenzpflicht' },
|
||||||
|
{ key: 'logging_required', label: 'Protokollierungspflicht' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function NewOptimizationPageInner() {
|
||||||
|
const router = useRouter()
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const fromAssessment = searchParams.get('from_assessment')
|
||||||
|
const [autoOptimizing, setAutoOptimizing] = useState(false)
|
||||||
|
const [title, setTitle] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!fromAssessment) return
|
||||||
|
setAutoOptimizing(true)
|
||||||
|
fetch(`/api/sdk/v1/maximizer/optimize-from-assessment/${fromAssessment}`, { method: 'POST' })
|
||||||
|
.then(r => r.ok ? r.json() : Promise.reject('failed'))
|
||||||
|
.then(data => router.push(`/sdk/compliance-optimizer/${data.id}`))
|
||||||
|
.catch(() => setAutoOptimizing(false))
|
||||||
|
}, [fromAssessment, router])
|
||||||
|
const [config, setConfig] = useState<Record<string, string>>({
|
||||||
|
automation_level: 'assistive', decision_binding: 'non_binding', decision_impact: 'low',
|
||||||
|
domain: 'general', data_type: 'non_personal', human_in_loop: 'required',
|
||||||
|
explainability: 'basic', risk_classification: 'minimal', legal_basis: 'contract',
|
||||||
|
transparency_required: 'false', logging_required: 'false',
|
||||||
|
model_type: 'rule_based', deployment_scope: 'internal',
|
||||||
|
})
|
||||||
|
const [preview, setPreview] = useState<Record<string, { zone: string }> | null>(null)
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
|
||||||
|
async function handlePreview() {
|
||||||
|
try {
|
||||||
|
const body = { ...config, transparency_required: config.transparency_required === 'true', logging_required: config.logging_required === 'true' }
|
||||||
|
const res = await fetch('/api/sdk/v1/maximizer/evaluate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
setPreview(data.zone_map || {})
|
||||||
|
}
|
||||||
|
} catch { /* silent */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
setSubmitting(true)
|
||||||
|
try {
|
||||||
|
const body = {
|
||||||
|
config: { ...config, transparency_required: config.transparency_required === 'true', logging_required: config.logging_required === 'true' },
|
||||||
|
title: title || 'Optimierung ' + new Date().toLocaleDateString('de-DE'),
|
||||||
|
}
|
||||||
|
const res = await fetch('/api/sdk/v1/maximizer/optimize', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
router.push(`/sdk/compliance-optimizer/${data.id}`)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (autoOptimizing) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto p-6 text-center py-24">
|
||||||
|
<div className="animate-pulse">
|
||||||
|
<span className="text-4xl">📊</span>
|
||||||
|
<h2 className="text-xl font-bold text-gray-900 mt-4 mb-2">Optimierung laeuft...</h2>
|
||||||
|
<p className="text-sm text-gray-500">Assessment wird analysiert und optimale Konfiguration berechnet.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto p-6">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 mb-1">Neue Optimierung</h1>
|
||||||
|
<p className="text-sm text-gray-500 mb-6">Konfigurieren Sie Ihren KI-Use-Case und finden Sie den maximalen regulatorischen Spielraum.</p>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Titel</label>
|
||||||
|
<input type="text" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="z.B. HR Bewerber-Ranking"
|
||||||
|
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||||
|
{DIMENSIONS.map((dim) => (
|
||||||
|
<div key={dim.key}>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{dim.label}
|
||||||
|
{preview && preview[dim.key] && (
|
||||||
|
<span className="ml-2"><ZoneBadge zone={preview[dim.key].zone as 'FORBIDDEN' | 'RESTRICTED' | 'SAFE'} /></span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={config[dim.key]}
|
||||||
|
onChange={(e) => { setConfig({ ...config, [dim.key]: e.target.value }); setPreview(null) }}
|
||||||
|
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm bg-white"
|
||||||
|
>
|
||||||
|
{dim.options.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{TOGGLE_DIMENSIONS.map((dim) => (
|
||||||
|
<div key={dim.key} className="flex items-center gap-3">
|
||||||
|
<input type="checkbox" checked={config[dim.key] === 'true'}
|
||||||
|
onChange={(e) => { setConfig({ ...config, [dim.key]: String(e.target.checked) }); setPreview(null) }}
|
||||||
|
className="h-4 w-4 rounded border-gray-300 text-blue-600" />
|
||||||
|
<label className="text-sm font-medium text-gray-700">
|
||||||
|
{dim.label}
|
||||||
|
{preview && preview[dim.key] && (
|
||||||
|
<span className="ml-2"><ZoneBadge zone={preview[dim.key].zone as 'FORBIDDEN' | 'RESTRICTED' | 'SAFE'} /></span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button onClick={handlePreview} className="border border-gray-300 text-gray-700 px-4 py-2 rounded-lg hover:bg-gray-50 text-sm">
|
||||||
|
Vorschau (3-Zonen-Check)
|
||||||
|
</button>
|
||||||
|
<button onClick={handleSubmit} disabled={submitting}
|
||||||
|
className="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700 text-sm font-medium disabled:opacity-50">
|
||||||
|
{submitting ? 'Optimiere...' : 'Optimieren'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NewOptimizationPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<div className="max-w-4xl mx-auto p-6 text-gray-500">Laden...</div>}>
|
||||||
|
<NewOptimizationPageInner />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { ZoneBadge } from '@/components/sdk/compliance-optimizer/ZoneBadge'
|
||||||
|
|
||||||
|
interface OptimizationSummary {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
is_compliant: boolean
|
||||||
|
constraint_version: string
|
||||||
|
created_at: string
|
||||||
|
zone_map: Record<string, { zone: 'FORBIDDEN' | 'RESTRICTED' | 'SAFE' }>
|
||||||
|
max_safe_config?: { safety_score: number; utility_score: number }
|
||||||
|
assessment_id?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function countZones(zoneMap: Record<string, { zone: string }>) {
|
||||||
|
let forbidden = 0, restricted = 0, safe = 0
|
||||||
|
for (const v of Object.values(zoneMap || {})) {
|
||||||
|
if (v.zone === 'FORBIDDEN') forbidden++
|
||||||
|
else if (v.zone === 'RESTRICTED') restricted++
|
||||||
|
else safe++
|
||||||
|
}
|
||||||
|
return { forbidden, restricted, safe }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ComplianceOptimizerPage() {
|
||||||
|
const [optimizations, setOptimizations] = useState<OptimizationSummary[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [total, setTotal] = useState(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchOptimizations()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function fetchOptimizations() {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
const res = await fetch('/api/sdk/v1/maximizer/optimizations?limit=20')
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
setOptimizations(data.optimizations || [])
|
||||||
|
setTotal(data.total || 0)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// silent
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-6xl mx-auto p-6">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Compliance Optimizer</h1>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
Regulatorischen Spielraum maximieren — KI-Use-Cases optimal konfigurieren
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/sdk/compliance-optimizer/new"
|
||||||
|
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 text-sm font-medium"
|
||||||
|
>
|
||||||
|
Neue Optimierung
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center py-12 text-gray-500">Laden...</div>
|
||||||
|
) : optimizations.length === 0 ? (
|
||||||
|
<div className="text-center py-12 bg-gray-50 rounded-lg border border-gray-200">
|
||||||
|
<p className="text-gray-600 mb-2">Noch keine Optimierungen durchgefuehrt.</p>
|
||||||
|
<Link href="/sdk/compliance-optimizer/new" className="text-blue-600 hover:underline text-sm">
|
||||||
|
Erste Optimierung starten
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Titel</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Zonen</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Quelle</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Datum</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200">
|
||||||
|
{optimizations.map((o) => {
|
||||||
|
const zones = countZones(o.zone_map)
|
||||||
|
return (
|
||||||
|
<tr key={o.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<Link href={`/sdk/compliance-optimizer/${o.id}`} className="text-blue-600 hover:underline font-medium text-sm">
|
||||||
|
{o.title || 'Ohne Titel'}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<ZoneBadge zone={o.is_compliant ? 'SAFE' : 'FORBIDDEN'} />
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-600">
|
||||||
|
{zones.forbidden > 0 && <span className="text-red-600 mr-2">{zones.forbidden} verboten</span>}
|
||||||
|
{zones.restricted > 0 && <span className="text-yellow-600 mr-2">{zones.restricted} eingeschraenkt</span>}
|
||||||
|
<span className="text-green-600">{zones.safe} erlaubt</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm">
|
||||||
|
{o.assessment_id ? (
|
||||||
|
<Link href={`/sdk/use-cases/${o.assessment_id}`} className="text-purple-600 hover:underline text-xs">
|
||||||
|
Assessment
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400 text-xs">Manuell</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-500">
|
||||||
|
{new Date(o.created_at).toLocaleDateString('de-DE')}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{total > 20 && (
|
||||||
|
<div className="px-4 py-3 bg-gray-50 text-sm text-gray-500">
|
||||||
|
{total} Optimierungen insgesamt
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
interface DeadlineConfig {
|
||||||
|
gracePeriodDays: number
|
||||||
|
reminderDays: number[]
|
||||||
|
suspendOnExpiry: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeadlineTab() {
|
||||||
|
// Phase 4: Deadline management — backend service pending (Core integration)
|
||||||
|
const config: DeadlineConfig = {
|
||||||
|
gracePeriodDays: 30,
|
||||||
|
reminderDays: [28, 21, 14, 7],
|
||||||
|
suspendOnExpiry: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold text-slate-900">Fristen & Erinnerungen</h2>
|
||||||
|
<span className="px-2 py-1 bg-yellow-100 text-yellow-700 rounded text-xs">In Vorbereitung</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 text-sm text-blue-800">
|
||||||
|
Das Fristen-System wird automatisch Erinnerungen an Nutzer senden, die neue Pflichtdokumente
|
||||||
|
noch nicht akzeptiert haben. Nach Ablauf der Frist wird der Account gesperrt bis die Zustimmung erfolgt.
|
||||||
|
Die E-Mail-Zustellung wird ueber den Core-Service in Production bereitgestellt.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div className="border border-slate-200 rounded-lg p-4">
|
||||||
|
<h3 className="text-sm font-medium text-slate-700">Nachfrist</h3>
|
||||||
|
<p className="text-2xl font-bold text-slate-900 mt-1">{config.gracePeriodDays} Tage</p>
|
||||||
|
<p className="text-xs text-slate-500 mt-1">Nach Veroeffentlichung eines Pflichtdokuments</p>
|
||||||
|
</div>
|
||||||
|
<div className="border border-slate-200 rounded-lg p-4">
|
||||||
|
<h3 className="text-sm font-medium text-slate-700">Erinnerungen</h3>
|
||||||
|
<p className="text-2xl font-bold text-slate-900 mt-1">{config.reminderDays.length}x</p>
|
||||||
|
<p className="text-xs text-slate-500 mt-1">
|
||||||
|
Tag {config.reminderDays.join(', ')} nach Veroeffentlichung
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="border border-slate-200 rounded-lg p-4">
|
||||||
|
<h3 className="text-sm font-medium text-slate-700">Auto-Sperrung</h3>
|
||||||
|
<p className="text-2xl font-bold text-slate-900 mt-1">{config.suspendOnExpiry ? 'Aktiv' : 'Inaktiv'}</p>
|
||||||
|
<p className="text-xs text-slate-500 mt-1">Account wird nach Fristablauf gesperrt</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border border-slate-200 rounded-lg p-4">
|
||||||
|
<h3 className="text-sm font-medium text-slate-700 mb-3">Erinnerungs-Timeline</h3>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{Array.from({ length: 30 }, (_, i) => {
|
||||||
|
const day = 30 - i
|
||||||
|
const isReminder = config.reminderDays.includes(day)
|
||||||
|
const isDeadline = day === 0
|
||||||
|
return (
|
||||||
|
<div key={i} className="flex-1 relative group">
|
||||||
|
<div className={`h-2 rounded-sm ${
|
||||||
|
isDeadline ? 'bg-red-500' : isReminder ? 'bg-yellow-400' : 'bg-slate-100'
|
||||||
|
}`} />
|
||||||
|
{isReminder && (
|
||||||
|
<span className="absolute -top-5 left-1/2 -translate-x-1/2 text-[10px] text-yellow-600 whitespace-nowrap">
|
||||||
|
Tag {day}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<div className="flex-none w-3 h-2 bg-red-500 rounded-sm" title="Sperrung" />
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between mt-1 text-[10px] text-slate-400">
|
||||||
|
<span>Veroeffentlichung</span>
|
||||||
|
<span>Sperrung</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Link href="/sdk/email-templates" className="text-sm text-purple-600 hover:underline">
|
||||||
|
E-Mail-Templates konfigurieren →
|
||||||
|
</Link>
|
||||||
|
<Link href="/sdk/dsr" className="text-sm text-purple-600 hover:underline">
|
||||||
|
Betroffenenrechte verwalten →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
const INTEGRATIONS = [
|
||||||
|
{
|
||||||
|
id: 'matrix',
|
||||||
|
name: 'Matrix Kommunikation',
|
||||||
|
description: 'Sichere, verschluesselte Kommunikation mit Betroffenen ueber Matrix-Protokoll. Wird in Production ueber den Core Communication Service bereitgestellt.',
|
||||||
|
status: 'planned',
|
||||||
|
icon: '💬',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'jitsi',
|
||||||
|
name: 'Jitsi Video-Meetings',
|
||||||
|
description: 'DSGVO-konforme Video-Konsultationen mit Betroffenen fuer komplexe Datenschutzanfragen. Wird ueber den Core Jitsi Service bereitgestellt.',
|
||||||
|
status: 'planned',
|
||||||
|
icon: '📹',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'oauth',
|
||||||
|
name: 'OAuth 2.0 Client-Verwaltung',
|
||||||
|
description: 'Verwaltung von OAuth-Clients fuer API-Zugriff auf Consent-Endpunkte. Authorization Code Flow mit PKCE-Support.',
|
||||||
|
status: 'planned',
|
||||||
|
icon: '🔑',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2fa',
|
||||||
|
name: 'Zwei-Faktor-Authentifizierung',
|
||||||
|
description: 'TOTP-basierte Zwei-Faktor-Authentifizierung fuer Admin-Zugang. Recovery-Codes fuer Notfallzugriff.',
|
||||||
|
status: 'planned',
|
||||||
|
icon: '🛡️',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'notifications',
|
||||||
|
name: 'Benachrichtigungssystem',
|
||||||
|
description: 'In-App und E-Mail Benachrichtigungen fuer Consent-Aenderungen, DSR-Fristen und Dokument-Updates. Praeferenz-Verwaltung pro Nutzer.',
|
||||||
|
status: 'planned',
|
||||||
|
icon: '🔔',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export function IntegrationStubs() {
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<h2 className="text-lg font-semibold text-slate-900">Integrationen</h2>
|
||||||
|
<span className="px-2 py-1 bg-blue-100 text-blue-700 rounded text-xs">Production-Anbindung</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
Diese Dienste werden in Production ueber die Core-Services bereitgestellt und sind
|
||||||
|
im SDK vorbereitet.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{INTEGRATIONS.map(integration => (
|
||||||
|
<div key={integration.id} className="border border-slate-200 rounded-lg p-4 bg-slate-50">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<span className="text-2xl">{integration.icon}</span>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="text-sm font-medium text-slate-800">{integration.name}</h3>
|
||||||
|
<span className="px-1.5 py-0.5 bg-yellow-100 text-yellow-700 rounded text-[10px]">Geplant</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-500 mt-1">{integration.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,18 +2,28 @@
|
|||||||
|
|
||||||
import type { Document, Version } from '../_types'
|
import type { Document, Version } from '../_types'
|
||||||
|
|
||||||
|
const STATUS_STYLES: Record<string, { label: string; color: string }> = {
|
||||||
|
draft: { label: 'Entwurf', color: 'bg-gray-100 text-gray-700' },
|
||||||
|
review: { label: 'Pruefung', color: 'bg-yellow-100 text-yellow-700' },
|
||||||
|
approved: { label: 'Genehmigt', color: 'bg-blue-100 text-blue-700' },
|
||||||
|
published: { label: 'Publiziert', color: 'bg-green-100 text-green-700' },
|
||||||
|
archived: { label: 'Archiviert', color: 'bg-gray-100 text-gray-400' },
|
||||||
|
rejected: { label: 'Abgelehnt', color: 'bg-red-100 text-red-700' },
|
||||||
|
}
|
||||||
|
|
||||||
export function VersionsTab({
|
export function VersionsTab({
|
||||||
loading,
|
loading, documents, versions, selectedDocument, setSelectedDocument,
|
||||||
documents,
|
onSubmitReview, onApprove, onReject, onPublish,
|
||||||
versions,
|
|
||||||
selectedDocument,
|
|
||||||
setSelectedDocument,
|
|
||||||
}: {
|
}: {
|
||||||
loading: boolean
|
loading: boolean
|
||||||
documents: Document[]
|
documents: Document[]
|
||||||
versions: Version[]
|
versions: Version[]
|
||||||
selectedDocument: string
|
selectedDocument: string
|
||||||
setSelectedDocument: (id: string) => void
|
setSelectedDocument: (id: string) => void
|
||||||
|
onSubmitReview?: (versionId: string) => void
|
||||||
|
onApprove?: (versionId: string) => void
|
||||||
|
onReject?: (versionId: string, comment: string) => void
|
||||||
|
onPublish?: (versionId: string) => void
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
@@ -27,73 +37,69 @@ export function VersionsTab({
|
|||||||
>
|
>
|
||||||
<option value="">Dokument auswaehlen...</option>
|
<option value="">Dokument auswaehlen...</option>
|
||||||
{documents.map((doc) => (
|
{documents.map((doc) => (
|
||||||
<option key={doc.id} value={doc.id}>
|
<option key={doc.id} value={doc.id}>{doc.name}</option>
|
||||||
{doc.name}
|
|
||||||
</option>
|
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{!selectedDocument ? (
|
{!selectedDocument ? (
|
||||||
<div className="text-center py-12 text-slate-500">
|
<div className="text-center py-12 text-slate-500">Bitte waehlen Sie ein Dokument aus</div>
|
||||||
Bitte waehlen Sie ein Dokument aus
|
|
||||||
</div>
|
|
||||||
) : loading ? (
|
) : loading ? (
|
||||||
<div className="text-center py-12 text-slate-500">Lade Versionen...</div>
|
<div className="text-center py-12 text-slate-500">Lade Versionen...</div>
|
||||||
) : versions.length === 0 ? (
|
) : versions.length === 0 ? (
|
||||||
<div className="text-center py-12 text-slate-500">
|
<div className="text-center py-12 text-slate-500">Keine Versionen vorhanden</div>
|
||||||
Keine Versionen vorhanden
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{versions.map((version) => (
|
{versions.map((version) => {
|
||||||
<div
|
const style = STATUS_STYLES[version.status] || STATUS_STYLES.draft
|
||||||
key={version.id}
|
return (
|
||||||
className="border border-slate-200 rounded-lg p-4 hover:border-purple-300 transition-colors"
|
<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 className="flex items-start justify-between">
|
<div>
|
||||||
<div>
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<span className="font-semibold text-slate-900">v{version.version}</span>
|
||||||
<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">
|
||||||
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 rounded text-xs">
|
{version.language.toUpperCase()}
|
||||||
{version.language.toUpperCase()}
|
</span>
|
||||||
</span>
|
<span className={`px-2 py-0.5 rounded text-xs ${style.color}`}>{style.label}</span>
|
||||||
<span
|
</div>
|
||||||
className={`px-2 py-0.5 rounded text-xs ${
|
<h3 className="text-slate-700">{version.title}</h3>
|
||||||
version.status === 'published'
|
<p className="text-sm text-slate-500 mt-1">
|
||||||
? 'bg-green-100 text-green-700'
|
Erstellt: {new Date(version.created_at).toLocaleDateString('de-DE')}
|
||||||
: version.status === 'draft'
|
{version.published_at && ` | Publiziert: ${new Date(version.published_at).toLocaleDateString('de-DE')}`}
|
||||||
? 'bg-yellow-100 text-yellow-700'
|
</p>
|
||||||
: 'bg-slate-100 text-slate-600'
|
</div>
|
||||||
}`}
|
<div className="flex gap-2 flex-wrap justify-end">
|
||||||
>
|
{version.status === 'draft' && onSubmitReview && (
|
||||||
{version.status}
|
<button onClick={() => onSubmitReview(version.id)}
|
||||||
</span>
|
className="px-3 py-1.5 text-sm text-white bg-yellow-500 hover:bg-yellow-600 rounded-lg">
|
||||||
|
Zur Pruefung
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{version.status === 'review' && onApprove && (
|
||||||
|
<button onClick={() => onApprove(version.id)}
|
||||||
|
className="px-3 py-1.5 text-sm text-white bg-blue-600 hover:bg-blue-700 rounded-lg">
|
||||||
|
Genehmigen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{version.status === 'review' && onReject && (
|
||||||
|
<button onClick={() => { const c = prompt('Ablehnungsgrund:'); if (c) onReject(version.id, c) }}
|
||||||
|
className="px-3 py-1.5 text-sm text-white bg-red-500 hover:bg-red-600 rounded-lg">
|
||||||
|
Ablehnen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{version.status === 'approved' && onPublish && (
|
||||||
|
<button onClick={() => onPublish(version.id)}
|
||||||
|
className="px-3 py-1.5 text-sm text-white bg-green-600 hover:bg-green-700 rounded-lg">
|
||||||
|
Publizieren
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</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>
|
)
|
||||||
))}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -277,6 +277,45 @@ export function useConsentData(activeTab: Tab, selectedDocument: string) {
|
|||||||
localStorage.setItem('sdk-email-templates', JSON.stringify(updated))
|
localStorage.setItem('sdk-email-templates', JSON.stringify(updated))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Document version workflow actions (via admin consent proxy → legal-documents backend)
|
||||||
|
async function submitVersionForReview(versionId: string) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/versions/${versionId}/submit-review`, {
|
||||||
|
method: 'POST', headers: authToken ? { 'Authorization': `Bearer ${authToken}` } : {},
|
||||||
|
})
|
||||||
|
if (res.ok && selectedDocument) await loadVersions(selectedDocument)
|
||||||
|
} catch (err) { console.error('Submit failed:', err) }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function approveVersion(versionId: string) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/versions/${versionId}/approve`, {
|
||||||
|
method: 'POST', headers: authToken ? { 'Authorization': `Bearer ${authToken}` } : {},
|
||||||
|
})
|
||||||
|
if (res.ok && selectedDocument) await loadVersions(selectedDocument)
|
||||||
|
} catch (err) { console.error('Approve failed:', err) }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rejectVersion(versionId: string, comment: string) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/versions/${versionId}/reject`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...(authToken ? { 'Authorization': `Bearer ${authToken}` } : {}) },
|
||||||
|
body: JSON.stringify({ comment }),
|
||||||
|
})
|
||||||
|
if (res.ok && selectedDocument) await loadVersions(selectedDocument)
|
||||||
|
} catch (err) { console.error('Reject failed:', err) }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function publishVersion(versionId: string) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/versions/${versionId}/publish`, {
|
||||||
|
method: 'POST', headers: authToken ? { 'Authorization': `Bearer ${authToken}` } : {},
|
||||||
|
})
|
||||||
|
if (res.ok) { if (selectedDocument) await loadVersions(selectedDocument); await loadDocuments() }
|
||||||
|
} catch (err) { console.error('Publish failed:', err) }
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
documents, versions, loading, error, setError,
|
documents, versions, loading, error, setError,
|
||||||
consentStats, dsrCounts, dsrOverview,
|
consentStats, dsrCounts, dsrOverview,
|
||||||
@@ -286,6 +325,7 @@ export function useConsentData(activeTab: Tab, selectedDocument: string) {
|
|||||||
savingTemplateId, savingProcessId,
|
savingTemplateId, savingProcessId,
|
||||||
saveApiEmailTemplate, saveApiGdprProcess,
|
saveApiEmailTemplate, saveApiGdprProcess,
|
||||||
loadApiEmailTemplates,
|
loadApiEmailTemplates,
|
||||||
|
submitVersionForReview, approveVersion, rejectVersion, publishVersion,
|
||||||
authToken, setAuthToken,
|
authToken, setAuthToken,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export const API_BASE = '/api/admin/consent'
|
export const API_BASE = '/api/admin/consent'
|
||||||
|
|
||||||
export type Tab = 'documents' | 'versions' | 'emails' | 'gdpr' | 'stats'
|
export type Tab = 'documents' | 'versions' | 'emails' | 'gdpr' | 'stats' | 'deadlines' | 'integrations'
|
||||||
|
|
||||||
export interface Document {
|
export interface Document {
|
||||||
id: string
|
id: string
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ import { GdprTab } from './_components/GdprTab'
|
|||||||
import { StatsTab } from './_components/StatsTab'
|
import { StatsTab } from './_components/StatsTab'
|
||||||
import { ConsentTemplateCreateModal } from './_components/ConsentTemplateCreateModal'
|
import { ConsentTemplateCreateModal } from './_components/ConsentTemplateCreateModal'
|
||||||
import { EmailTemplateEditModal, EmailTemplatePreviewModal } from './_components/EmailTemplateModals'
|
import { EmailTemplateEditModal, EmailTemplatePreviewModal } from './_components/EmailTemplateModals'
|
||||||
|
import { DeadlineTab } from './_components/DeadlineTab'
|
||||||
|
import { IntegrationStubs } from './_components/IntegrationStubs'
|
||||||
|
|
||||||
export default function ConsentManagementPage() {
|
export default function ConsentManagementPage() {
|
||||||
const { state } = useSDK()
|
const { state } = useSDK()
|
||||||
@@ -45,6 +47,7 @@ export default function ConsentManagementPage() {
|
|||||||
savingTemplateId, savingProcessId,
|
savingTemplateId, savingProcessId,
|
||||||
saveApiEmailTemplate, saveApiGdprProcess,
|
saveApiEmailTemplate, saveApiGdprProcess,
|
||||||
loadApiEmailTemplates,
|
loadApiEmailTemplates,
|
||||||
|
submitVersionForReview, approveVersion, rejectVersion, publishVersion,
|
||||||
authToken, setAuthToken,
|
authToken, setAuthToken,
|
||||||
} = useConsentData(activeTab, selectedDocument)
|
} = useConsentData(activeTab, selectedDocument)
|
||||||
|
|
||||||
@@ -54,6 +57,8 @@ export default function ConsentManagementPage() {
|
|||||||
{ id: 'emails', label: 'E-Mail Vorlagen' },
|
{ id: 'emails', label: 'E-Mail Vorlagen' },
|
||||||
{ id: 'gdpr', label: 'DSGVO Prozesse' },
|
{ id: 'gdpr', label: 'DSGVO Prozesse' },
|
||||||
{ id: 'stats', label: 'Statistiken' },
|
{ id: 'stats', label: 'Statistiken' },
|
||||||
|
{ id: 'deadlines', label: 'Fristen' },
|
||||||
|
{ id: 'integrations', label: 'Integrationen' },
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -128,6 +133,10 @@ export default function ConsentManagementPage() {
|
|||||||
versions={versions}
|
versions={versions}
|
||||||
selectedDocument={selectedDocument}
|
selectedDocument={selectedDocument}
|
||||||
setSelectedDocument={setSelectedDocument}
|
setSelectedDocument={setSelectedDocument}
|
||||||
|
onSubmitReview={submitVersionForReview}
|
||||||
|
onApprove={approveVersion}
|
||||||
|
onReject={rejectVersion}
|
||||||
|
onPublish={publishVersion}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -157,6 +166,10 @@ export default function ConsentManagementPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'stats' && <StatsTab consentStats={consentStats} />}
|
{activeTab === 'stats' && <StatsTab consentStats={consentStats} />}
|
||||||
|
|
||||||
|
{activeTab === 'deadlines' && <DeadlineTab />}
|
||||||
|
|
||||||
|
{activeTab === 'integrations' && <IntegrationStubs />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,354 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import {
|
||||||
|
CATEGORY_VENDORS, countNonEWRVendors, isEWR, isOutsideEWR,
|
||||||
|
} from '@/components/sdk/cookie-banner-vendors'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cookie Banner Live-Vorschau — simulates a real website with the banner.
|
||||||
|
*
|
||||||
|
* Purpose: Test the full consent flow end-to-end:
|
||||||
|
* 1. Visitor lands on simulated website → banner appears
|
||||||
|
* 2. Visitor makes consent choice (accept/reject/custom + EWR toggle)
|
||||||
|
* 3. Consent is recorded via Banner API (POST /banner/consent)
|
||||||
|
* 4. Admin can verify in /sdk/consent-management and /sdk/einwilligungen
|
||||||
|
*
|
||||||
|
* This page runs OUTSIDE the SDK layout to simulate a real website experience.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Use Next.js API proxy to avoid SSL cert issues with direct backend calls
|
||||||
|
const API_BASE = '/api/sdk/v1/banner'
|
||||||
|
const SITE_ID = 'preview-test-site'
|
||||||
|
const TENANT_ID = '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||||
|
|
||||||
|
interface ConsentRecord {
|
||||||
|
id: string
|
||||||
|
categories: string[]
|
||||||
|
ewrOnly: boolean
|
||||||
|
blockedVendors: string[]
|
||||||
|
timestamp: string
|
||||||
|
device_fingerprint: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateFingerprint(): string {
|
||||||
|
const nav = typeof navigator !== 'undefined' ? navigator : null
|
||||||
|
const seed = [
|
||||||
|
nav?.userAgent || '',
|
||||||
|
nav?.language || '',
|
||||||
|
screen?.width || 0,
|
||||||
|
screen?.height || 0,
|
||||||
|
new Date().getTimezoneOffset(),
|
||||||
|
].join('|')
|
||||||
|
let hash = 0
|
||||||
|
for (let i = 0; i < seed.length; i++) {
|
||||||
|
hash = ((hash << 5) - hash + seed.charCodeAt(i)) | 0
|
||||||
|
}
|
||||||
|
return `fp-${Math.abs(hash).toString(36)}-${Date.now().toString(36)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CookieBannerPreviewPage() {
|
||||||
|
const [consent, setConsent] = useState<ConsentRecord | null>(null)
|
||||||
|
const [showBanner, setShowBanner] = useState(true)
|
||||||
|
const [ewrOnly, setEwrOnly] = useState(false)
|
||||||
|
const [categories, setCategories] = useState({ necessary: true, statistics: false, marketing: false, functional: false })
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [apiResult, setApiResult] = useState<any>(null)
|
||||||
|
const [fingerprint] = useState(() => generateFingerprint())
|
||||||
|
|
||||||
|
// Check for existing consent on this simulated site
|
||||||
|
useEffect(() => {
|
||||||
|
async function check() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`${API_BASE}/banner/consent?site_id=${SITE_ID}&device_fingerprint=${fingerprint}`,
|
||||||
|
{ headers: { 'x-tenant-id': TENANT_ID } },
|
||||||
|
)
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.has_consent) {
|
||||||
|
setConsent(data.consent)
|
||||||
|
setShowBanner(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* first visit */ }
|
||||||
|
}
|
||||||
|
check()
|
||||||
|
}, [fingerprint])
|
||||||
|
|
||||||
|
const saveConsent = useCallback(async (cats: typeof categories) => {
|
||||||
|
setSaving(true)
|
||||||
|
const blocked: string[] = []
|
||||||
|
if (ewrOnly) {
|
||||||
|
for (const [key, cat] of Object.entries(CATEGORY_VENDORS)) {
|
||||||
|
if (!cats[key as keyof typeof cats]) continue
|
||||||
|
for (const v of cat.vendors) {
|
||||||
|
if (isOutsideEWR(v.country)) blocked.push(v.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/banner/consent`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'x-tenant-id': TENANT_ID },
|
||||||
|
body: JSON.stringify({
|
||||||
|
site_id: SITE_ID,
|
||||||
|
device_fingerprint: fingerprint,
|
||||||
|
categories: Object.entries(cats).filter(([, v]) => v).map(([k]) => k),
|
||||||
|
vendors: [],
|
||||||
|
consent_string: JSON.stringify({ ewrOnly, blockedVendors: blocked }),
|
||||||
|
user_agent: navigator.userAgent,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
setApiResult(data)
|
||||||
|
setConsent({ ...data, ewrOnly, blockedVendors: blocked, timestamp: new Date().toISOString() })
|
||||||
|
setShowBanner(false)
|
||||||
|
} catch (err: any) {
|
||||||
|
setApiResult({ error: err.message })
|
||||||
|
// Close banner even on error — don't trap the user
|
||||||
|
setShowBanner(false)
|
||||||
|
}
|
||||||
|
setSaving(false)
|
||||||
|
}, [ewrOnly, fingerprint])
|
||||||
|
|
||||||
|
const nonEWRCount = countNonEWRVendors()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-white">
|
||||||
|
{/* Simulated Website Header */}
|
||||||
|
<header className="bg-slate-800 text-white px-8 py-4">
|
||||||
|
<div className="max-w-6xl mx-auto flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 bg-blue-500 rounded-lg" />
|
||||||
|
<span className="font-semibold text-lg">MusterShop GmbH</span>
|
||||||
|
</div>
|
||||||
|
<nav className="flex items-center gap-6 text-sm text-slate-300">
|
||||||
|
<span className="hover:text-white cursor-pointer">Produkte</span>
|
||||||
|
<span className="hover:text-white cursor-pointer">Ueber uns</span>
|
||||||
|
<span className="hover:text-white cursor-pointer">Kontakt</span>
|
||||||
|
<span className="px-3 py-1.5 bg-blue-600 text-white rounded-lg text-sm">Warenkorb (2)</span>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Simulated Website Content */}
|
||||||
|
<main className="max-w-6xl mx-auto px-8 py-12">
|
||||||
|
<div className="grid grid-cols-3 gap-8">
|
||||||
|
<div className="col-span-2 space-y-6">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900">Willkommen bei MusterShop</h1>
|
||||||
|
<p className="text-gray-600 leading-relaxed">
|
||||||
|
Dies ist eine simulierte Website um den Cookie-Banner zu testen.
|
||||||
|
Die Consent-Daten werden ueber die echte Banner-API gespeichert und
|
||||||
|
erscheinen in Ihrem CMP unter Consent-Records und Consent-Verwaltung.
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{['Premium Paket', 'Standard Paket', 'Starter Paket', 'Enterprise'].map(p => (
|
||||||
|
<div key={p} className="bg-gray-50 border border-gray-200 rounded-xl p-6">
|
||||||
|
<div className="w-full h-24 bg-gray-200 rounded-lg mb-3" />
|
||||||
|
<h3 className="font-semibold text-gray-900">{p}</h3>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">Lorem ipsum dolor sit amet</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* API Debug Panel */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="bg-slate-50 border border-slate-200 rounded-xl p-4">
|
||||||
|
<h3 className="font-semibold text-slate-800 text-sm flex items-center gap-2">
|
||||||
|
<svg className="w-4 h-4 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
|
||||||
|
</svg>
|
||||||
|
API Debug
|
||||||
|
</h3>
|
||||||
|
<div className="mt-3 space-y-2 text-xs">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-slate-500">Site ID</span>
|
||||||
|
<code className="text-slate-700">{SITE_ID}</code>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-slate-500">Fingerprint</span>
|
||||||
|
<code className="text-slate-700 truncate ml-2">{fingerprint}</code>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-slate-500">Consent</span>
|
||||||
|
<span className={consent ? 'text-green-600 font-medium' : 'text-amber-600'}>
|
||||||
|
{consent ? 'Gespeichert' : 'Ausstehend'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{consent && (
|
||||||
|
<>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-slate-500">Kategorien</span>
|
||||||
|
<span className="text-slate-700">{consent.categories?.join(', ')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-slate-500">EWR-Only</span>
|
||||||
|
<span className={consent.ewrOnly ? 'text-blue-600' : 'text-slate-400'}>
|
||||||
|
{consent.ewrOnly ? 'Ja' : 'Nein'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{consent.blockedVendors?.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-500">Blockiert:</span>
|
||||||
|
<div className="mt-1 flex flex-wrap gap-1">
|
||||||
|
{consent.blockedVendors.map(v => (
|
||||||
|
<span key={v} className="px-1.5 py-0.5 bg-red-100 text-red-700 rounded text-[10px]">{v}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{consent && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setConsent(null); setShowBanner(true); setApiResult(null) }}
|
||||||
|
className="mt-3 w-full text-xs text-purple-600 hover:text-purple-700 underline"
|
||||||
|
>
|
||||||
|
Consent zuruecksetzen (Banner erneut anzeigen)
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{apiResult && (
|
||||||
|
<div className="bg-slate-900 text-green-400 rounded-xl p-4 text-xs font-mono overflow-auto max-h-48">
|
||||||
|
<div className="text-slate-500 mb-1">POST /banner/consent Response:</div>
|
||||||
|
{JSON.stringify(apiResult, null, 2)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="bg-purple-50 border border-purple-200 rounded-xl p-4 text-xs text-purple-800">
|
||||||
|
<div className="font-semibold mb-1">Pruefen Sie das Ergebnis in:</div>
|
||||||
|
<ul className="space-y-1 mt-2">
|
||||||
|
<li><a href="/sdk/consent-management" className="underline hover:text-purple-600">Consent-Verwaltung</a></li>
|
||||||
|
<li><a href="/sdk/einwilligungen" className="underline hover:text-purple-600">Consent-Records</a></li>
|
||||||
|
<li><a href="/sdk/dsr" className="underline hover:text-purple-600">DSR Portal</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Simulated Website Footer */}
|
||||||
|
<footer className="bg-slate-100 border-t border-slate-200 px-8 py-6 mt-12">
|
||||||
|
<div className="max-w-6xl mx-auto flex items-center justify-between text-sm text-slate-500">
|
||||||
|
<span>MusterShop GmbH — Simulierte Test-Website</span>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button onClick={() => setShowBanner(true)} className="underline hover:text-purple-600">
|
||||||
|
Cookie-Einstellungen
|
||||||
|
</button>
|
||||||
|
<span>Datenschutz</span>
|
||||||
|
<span>Impressum</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
{/* === REAL COOKIE BANNER === */}
|
||||||
|
{showBanner && (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 bg-black/40 z-[9998]" />
|
||||||
|
<div className="fixed bottom-0 left-0 right-0 z-[9999]">
|
||||||
|
<div className="max-w-3xl mx-auto m-4 bg-white rounded-2xl shadow-2xl border border-gray-200 overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="px-6 pt-5 pb-3">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900">Cookie-Einstellungen</h2>
|
||||||
|
<p className="text-sm text-gray-600 mt-1">
|
||||||
|
Waehlen Sie, welche Cookie-Kategorien Sie zulassen moechten.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/* EWR Toggle */}
|
||||||
|
<div className="flex flex-col items-end gap-1 shrink-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`text-xs font-medium ${ewrOnly ? 'text-blue-700' : 'text-gray-500'}`}>
|
||||||
|
Nur EU/EWR
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setEwrOnly(!ewrOnly)}
|
||||||
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors cursor-pointer ${
|
||||||
|
ewrOnly ? 'bg-blue-600' : 'bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform ${
|
||||||
|
ewrOnly ? 'translate-x-6' : 'translate-x-1'
|
||||||
|
}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Categories */}
|
||||||
|
<div className="px-6 pb-3 space-y-1.5 max-h-[40vh] overflow-y-auto border-t border-gray-100 pt-3">
|
||||||
|
{Object.entries(CATEGORY_VENDORS).map(([key, cat]) => {
|
||||||
|
const checked = key === 'necessary' ? true : categories[key as keyof typeof categories]
|
||||||
|
const nonEU = cat.vendors.filter(v => isOutsideEWR(v.country))
|
||||||
|
const blocked = ewrOnly && checked ? nonEU.length : 0
|
||||||
|
return (
|
||||||
|
<div key={key} className="flex items-center justify-between gap-3 px-4 py-2.5 border border-gray-100 rounded-lg bg-gray-50/50">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-gray-900">
|
||||||
|
{cat.label}
|
||||||
|
<span className="ml-2 text-xs font-normal text-gray-400">
|
||||||
|
{blocked > 0 ? `${cat.vendors.length - blocked} aktiv, ${blocked} blockiert` : `${cat.vendors.length} Verarbeiter`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500">{cat.description}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => key !== 'necessary' && setCategories(prev => ({ ...prev, [key]: !prev[key as keyof typeof prev] }))}
|
||||||
|
disabled={key === 'necessary'}
|
||||||
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors shrink-0 ${
|
||||||
|
checked ? (key === 'necessary' ? 'bg-gray-400' : 'bg-purple-600') : 'bg-gray-200'
|
||||||
|
} ${key === 'necessary' ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'}`}
|
||||||
|
>
|
||||||
|
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform ${
|
||||||
|
checked ? 'translate-x-6' : 'translate-x-1'
|
||||||
|
}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Buttons */}
|
||||||
|
<div className="px-6 py-4 bg-gray-50 border-t border-gray-100">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => saveConsent({ necessary: true, statistics: true, marketing: true, functional: true })}
|
||||||
|
disabled={saving}
|
||||||
|
className="flex-1 px-4 py-2.5 bg-purple-600 text-white rounded-lg font-medium hover:bg-purple-700 transition-colors text-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saving ? 'Speichern...' : 'Alle akzeptieren'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => saveConsent(categories)}
|
||||||
|
disabled={saving}
|
||||||
|
className="flex-1 px-4 py-2.5 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300 transition-colors text-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Auswahl speichern
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between mt-3">
|
||||||
|
<button
|
||||||
|
onClick={() => saveConsent({ necessary: true, statistics: false, marketing: false, functional: false })}
|
||||||
|
className="text-xs text-gray-500 hover:text-gray-700 underline"
|
||||||
|
>
|
||||||
|
Nur notwendige Cookies
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center gap-3 text-xs text-gray-400">
|
||||||
|
<span>Datenschutzerklaerung</span>
|
||||||
|
<span>Impressum</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -18,6 +18,10 @@ export const CATEGORIES: { key: string; label: string; types: string[] | null }[
|
|||||||
{ key: 'cloud', label: 'Cloud', types: ['cloud_service_agreement'] },
|
{ key: 'cloud', label: 'Cloud', types: ['cloud_service_agreement'] },
|
||||||
{ key: 'misc', label: 'Weitere', types: ['community_guidelines', 'copyright_policy', 'data_usage_clause'] },
|
{ key: 'misc', label: 'Weitere', types: ['community_guidelines', 'copyright_policy', 'data_usage_clause'] },
|
||||||
{ key: 'dsfa', label: 'DSFA', types: ['dsfa'] },
|
{ key: 'dsfa', label: 'DSFA', types: ['dsfa'] },
|
||||||
|
{ key: 'dsr', label: 'DSR-Prozesse', types: [
|
||||||
|
'dsr_process_art15', 'dsr_process_art16', 'dsr_process_art17',
|
||||||
|
'dsr_process_art18', 'dsr_process_art19', 'dsr_process_art20', 'dsr_process_art21',
|
||||||
|
]},
|
||||||
]
|
]
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|||||||
@@ -13,20 +13,21 @@ export function LoadingSpinner() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { PublicFormConfig as SettingsTabContent } from './PublicFormConfig'
|
||||||
|
|
||||||
export function SettingsTab() {
|
export function SettingsTab() {
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-xl border border-gray-200 p-8 text-center">
|
<div className="space-y-6">
|
||||||
<div className="w-16 h-16 mx-auto bg-gray-100 rounded-full flex items-center justify-center mb-4">
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||||
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<SettingsTabContent />
|
||||||
<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" />
|
</div>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||||
</svg>
|
<h3 className="text-base font-semibold text-slate-900 mb-2">Workflow-Konfiguration</h3>
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
SLA-Fristen, automatische Zuweisungen und Eskalationsregeln
|
||||||
|
werden in Production ueber den Core-Service konfiguriert.
|
||||||
|
</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
interface PublicFormSettings {
|
||||||
|
enabled: boolean
|
||||||
|
formUrl: string
|
||||||
|
allowedTypes: string[]
|
||||||
|
requireIdentity: boolean
|
||||||
|
customCss: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const DSR_TYPES = [
|
||||||
|
{ value: 'access', label: 'Auskunft (Art. 15)' },
|
||||||
|
{ value: 'rectification', label: 'Berichtigung (Art. 16)' },
|
||||||
|
{ value: 'erasure', label: 'Loeschung (Art. 17)' },
|
||||||
|
{ value: 'restriction', label: 'Einschraenkung (Art. 18)' },
|
||||||
|
{ value: 'portability', label: 'Datenportabilitaet (Art. 20)' },
|
||||||
|
{ value: 'objection', label: 'Widerspruch (Art. 21)' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function PublicFormConfig() {
|
||||||
|
const [settings, setSettings] = useState<PublicFormSettings>({
|
||||||
|
enabled: false,
|
||||||
|
formUrl: '',
|
||||||
|
allowedTypes: ['access', 'erasure', 'portability'],
|
||||||
|
requireIdentity: true,
|
||||||
|
customCss: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-base font-semibold text-slate-900">Oeffentliches DSR-Formular</h3>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={settings.enabled}
|
||||||
|
onChange={e => setSettings({ ...settings, enabled: e.target.checked })}
|
||||||
|
className="rounded border-gray-300 text-purple-600" />
|
||||||
|
<span className="text-sm text-slate-600">Aktiviert</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!settings.enabled ? (
|
||||||
|
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4 text-sm text-gray-600">
|
||||||
|
Das oeffentliche DSR-Formular ermoeglicht Betroffenen, Datenschutzanfragen direkt
|
||||||
|
ueber Ihre Website einzureichen — ohne Anmeldung. Aktivieren Sie es, um den
|
||||||
|
Embed-Code zu generieren.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-1">Erlaubte Anfragetypen</label>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{DSR_TYPES.map(type => (
|
||||||
|
<label key={type.value} className="flex items-center gap-2 text-sm text-slate-600">
|
||||||
|
<input type="checkbox"
|
||||||
|
checked={settings.allowedTypes.includes(type.value)}
|
||||||
|
onChange={e => {
|
||||||
|
const types = e.target.checked
|
||||||
|
? [...settings.allowedTypes, type.value]
|
||||||
|
: settings.allowedTypes.filter(t => t !== type.value)
|
||||||
|
setSettings({ ...settings, allowedTypes: types })
|
||||||
|
}}
|
||||||
|
className="rounded border-gray-300 text-purple-600" />
|
||||||
|
{type.label}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={settings.requireIdentity}
|
||||||
|
onChange={e => setSettings({ ...settings, requireIdentity: e.target.checked })}
|
||||||
|
className="rounded border-gray-300 text-purple-600" />
|
||||||
|
<span className="text-sm text-slate-600">Identitaetsnachweis erforderlich</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="bg-slate-50 border border-slate-200 rounded-lg p-4">
|
||||||
|
<h4 className="text-sm font-medium text-slate-700 mb-2">Embed-Code</h4>
|
||||||
|
<pre className="text-xs font-mono bg-white border border-slate-200 rounded p-3 overflow-x-auto">
|
||||||
|
{`<iframe
|
||||||
|
src="https://ihre-domain.breakpilot.ai/dsr/public-form"
|
||||||
|
width="100%"
|
||||||
|
height="600"
|
||||||
|
frameborder="0"
|
||||||
|
title="Datenschutzanfrage"
|
||||||
|
></iframe>`}
|
||||||
|
</pre>
|
||||||
|
<p className="text-xs text-slate-500 mt-2">
|
||||||
|
Embed-Code wird nach Anbindung an Production generiert.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -35,7 +35,7 @@ const EINWILLIGUNGEN_TABS = [
|
|||||||
{
|
{
|
||||||
id: 'cookie-banner',
|
id: 'cookie-banner',
|
||||||
label: 'Cookie-Banner',
|
label: 'Cookie-Banner',
|
||||||
href: '/sdk/einwilligungen/cookie-banner',
|
href: '/sdk/cookie-banner',
|
||||||
icon: Cookie,
|
icon: Cookie,
|
||||||
description: 'Cookie-Consent konfigurieren',
|
description: 'Cookie-Consent konfigurieren',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ function CatalogContent() {
|
|||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
href="/sdk/einwilligungen/cookie-banner"
|
href="/sdk/cookie-banner"
|
||||||
className="bg-white rounded-xl border border-slate-200 p-4 hover:border-indigo-300 hover:shadow-md transition-all group"
|
className="bg-white rounded-xl border border-slate-200 p-4 hover:border-indigo-300 hover:shadow-md transition-all group"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|||||||
@@ -1,141 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { CookieBannerConfig, SupportedLanguage } from '@/lib/sdk/einwilligungen/types'
|
|
||||||
|
|
||||||
interface BannerPreviewProps {
|
|
||||||
config: CookieBannerConfig | null
|
|
||||||
language: SupportedLanguage
|
|
||||||
device: 'desktop' | 'tablet' | 'mobile'
|
|
||||||
}
|
|
||||||
|
|
||||||
export 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' }}
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<div className="relative bg-slate-50 min-h-[400px]">
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<div className="absolute inset-0 bg-black/40" />
|
|
||||||
|
|
||||||
<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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { ChevronDown, ChevronRight } from 'lucide-react'
|
|
||||||
import { CookieBannerConfig, SupportedLanguage } from '@/lib/sdk/einwilligungen/types'
|
|
||||||
|
|
||||||
interface CategoryListProps {
|
|
||||||
config: CookieBannerConfig | null
|
|
||||||
language: SupportedLanguage
|
|
||||||
}
|
|
||||||
|
|
||||||
export 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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
-152
@@ -1,152 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useState, useMemo } from 'react'
|
|
||||||
import { useSDK } from '@/lib/sdk'
|
|
||||||
import { useEinwilligungen } from '@/lib/sdk/einwilligungen/context'
|
|
||||||
import {
|
|
||||||
generateCookieBannerConfig,
|
|
||||||
DEFAULT_COOKIE_BANNER_TEXTS,
|
|
||||||
DEFAULT_COOKIE_BANNER_STYLING,
|
|
||||||
} from '@/lib/sdk/einwilligungen/generator/cookie-banner'
|
|
||||||
import {
|
|
||||||
CookieBannerStyling,
|
|
||||||
CookieBannerTexts,
|
|
||||||
SupportedLanguage,
|
|
||||||
} from '@/lib/sdk/einwilligungen/types'
|
|
||||||
import { Cookie, Settings, Palette, Code, Monitor, Smartphone, Tablet } from 'lucide-react'
|
|
||||||
import Link from 'next/link'
|
|
||||||
import { ArrowLeft } from 'lucide-react'
|
|
||||||
import { StylingForm } from './StylingForm'
|
|
||||||
import { TextsForm } from './TextsForm'
|
|
||||||
import { BannerPreview } from './BannerPreview'
|
|
||||||
import { EmbedCodeViewer } from './EmbedCodeViewer'
|
|
||||||
import { CategoryList } from './CategoryList'
|
|
||||||
|
|
||||||
export 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">
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<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>
|
|
||||||
<BannerPreview config={config} language={language} device={device} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useState, useMemo } from 'react'
|
|
||||||
import { Copy, Check } from 'lucide-react'
|
|
||||||
import { CookieBannerConfig } from '@/lib/sdk/einwilligungen/types'
|
|
||||||
import { generateEmbedCode } from '@/lib/sdk/einwilligungen/generator/cookie-banner'
|
|
||||||
|
|
||||||
interface EmbedCodeViewerProps {
|
|
||||||
config: CookieBannerConfig | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export 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">
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
{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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { CookieBannerStyling } from '@/lib/sdk/einwilligungen/types'
|
|
||||||
|
|
||||||
interface StylingFormProps {
|
|
||||||
styling: CookieBannerStyling
|
|
||||||
onChange: (styling: CookieBannerStyling) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function StylingForm({ styling, onChange }: StylingFormProps) {
|
|
||||||
const handleChange = (field: keyof CookieBannerStyling, value: string | number) => {
|
|
||||||
onChange({ ...styling, [field]: value })
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { CookieBannerTexts, SupportedLanguage } from '@/lib/sdk/einwilligungen/types'
|
|
||||||
|
|
||||||
interface TextsFormProps {
|
|
||||||
texts: CookieBannerTexts
|
|
||||||
language: SupportedLanguage
|
|
||||||
onChange: (texts: CookieBannerTexts) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export 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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,18 +1,5 @@
|
|||||||
'use client'
|
import { redirect } from 'next/navigation'
|
||||||
|
|
||||||
/**
|
export default function CookieBannerRedirect() {
|
||||||
* Cookie Banner Configuration Page
|
redirect('/sdk/cookie-banner')
|
||||||
*
|
|
||||||
* Konfiguriert den Cookie-Banner basierend auf dem Datenpunktkatalog.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { EinwilligungenProvider } from '@/lib/sdk/einwilligungen/context'
|
|
||||||
import { CookieBannerContent } from './_components/CookieBannerContent'
|
|
||||||
|
|
||||||
export default function CookieBannerPage() {
|
|
||||||
return (
|
|
||||||
<EinwilligungenProvider>
|
|
||||||
<CookieBannerContent />
|
|
||||||
</EinwilligungenProvider>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -274,7 +274,7 @@ function RetentionTimeline({ dataPoints, language }: RetentionTimelineProps) {
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
interface ExportOptionsProps {
|
interface ExportOptionsProps {
|
||||||
onExport: (format: 'csv' | 'json' | 'pdf') => void
|
onExport: (format: 'csv' | 'json') => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function ExportOptions({ onExport }: ExportOptionsProps) {
|
function ExportOptions({ onExport }: ExportOptionsProps) {
|
||||||
@@ -294,13 +294,6 @@ function ExportOptions({ onExport }: ExportOptionsProps) {
|
|||||||
<Download className="w-4 h-4" />
|
<Download className="w-4 h-4" />
|
||||||
JSON
|
JSON
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
onClick={() => onExport('pdf')}
|
|
||||||
className="flex items-center gap-2 px-3 py-2 bg-indigo-600 text-white rounded-lg text-sm hover:bg-indigo-700"
|
|
||||||
>
|
|
||||||
<Download className="w-4 h-4" />
|
|
||||||
PDF
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -332,7 +325,7 @@ function RetentionContent() {
|
|||||||
}, [allDataPoints, filterCategory])
|
}, [allDataPoints, filterCategory])
|
||||||
|
|
||||||
// Handle export
|
// Handle export
|
||||||
const handleExport = (format: 'csv' | 'json' | 'pdf') => {
|
const handleExport = (format: 'csv' | 'json') => {
|
||||||
if (format === 'csv') {
|
if (format === 'csv') {
|
||||||
const headers = ['Code', 'Name', 'Kategorie', 'Loeschfrist', 'Rechtsgrundlage']
|
const headers = ['Code', 'Name', 'Kategorie', 'Loeschfrist', 'Rechtsgrundlage']
|
||||||
const rows = allDataPoints.map((dp) => [
|
const rows = allDataPoints.map((dp) => [
|
||||||
@@ -354,8 +347,6 @@ function RetentionContent() {
|
|||||||
legalBasis: dp.legalBasis,
|
legalBasis: dp.legalBasis,
|
||||||
}))
|
}))
|
||||||
downloadFile(JSON.stringify(data, null, 2), 'loeschfristen.json', 'application/json')
|
downloadFile(JSON.stringify(data, null, 2), 'loeschfristen.json', 'application/json')
|
||||||
} else {
|
|
||||||
alert('PDF-Export wird noch implementiert.')
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,11 +15,16 @@ interface EditorTabProps {
|
|||||||
onPublish: () => void
|
onPublish: () => void
|
||||||
onPreview: () => void
|
onPreview: () => void
|
||||||
onBack: () => void
|
onBack: () => void
|
||||||
|
onSubmitForReview?: () => void
|
||||||
|
onApprove?: (comment?: string) => void
|
||||||
|
onReject?: (comment: string) => void
|
||||||
|
onSendTest?: (email: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EditorTab({
|
export function EditorTab({
|
||||||
template, version, subject, html, previewHtml, saving,
|
template, version, subject, html, previewHtml, saving,
|
||||||
onSubjectChange, onHtmlChange, onSave, onPublish, onPreview, onBack,
|
onSubjectChange, onHtmlChange, onSave, onPublish, onPreview, onBack,
|
||||||
|
onSubmitForReview, onApprove, onReject, onSendTest,
|
||||||
}: EditorTabProps) {
|
}: EditorTabProps) {
|
||||||
if (!template) {
|
if (!template) {
|
||||||
return (
|
return (
|
||||||
@@ -46,30 +51,56 @@ export function EditorTab({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2 flex-wrap">
|
||||||
<button
|
{/* Save — always available for draft/review */}
|
||||||
onClick={onSave}
|
{(!version || version.status === 'draft' || version.status === 'review') && (
|
||||||
disabled={saving}
|
<button onClick={onSave} disabled={saving}
|
||||||
className="px-3 py-1.5 bg-purple-600 text-white rounded-lg text-sm hover:bg-purple-700 disabled:opacity-50"
|
className="px-3 py-1.5 bg-purple-600 text-white rounded-lg text-sm hover:bg-purple-700 disabled:opacity-50">
|
||||||
>
|
{saving ? 'Speichern...' : 'Version speichern'}
|
||||||
{saving ? 'Speichern...' : 'Version speichern'}
|
</button>
|
||||||
</button>
|
)}
|
||||||
{version && version.status !== 'published' && (
|
{/* Submit for Review — only for draft */}
|
||||||
<button
|
{version && version.status === 'draft' && onSubmitForReview && (
|
||||||
onClick={onPublish}
|
<button onClick={onSubmitForReview} disabled={saving}
|
||||||
disabled={saving}
|
className="px-3 py-1.5 bg-yellow-500 text-white rounded-lg text-sm hover:bg-yellow-600 disabled:opacity-50">
|
||||||
className="px-3 py-1.5 bg-green-600 text-white rounded-lg text-sm hover:bg-green-700 disabled:opacity-50"
|
Zur Pruefung einreichen
|
||||||
>
|
</button>
|
||||||
|
)}
|
||||||
|
{/* Approve — only for review status (DSB) */}
|
||||||
|
{version && version.status === 'review' && onApprove && (
|
||||||
|
<button onClick={() => onApprove()} disabled={saving}
|
||||||
|
className="px-3 py-1.5 bg-blue-600 text-white rounded-lg text-sm hover:bg-blue-700 disabled:opacity-50">
|
||||||
|
Genehmigen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{/* Reject — only for review status (DSB) */}
|
||||||
|
{version && version.status === 'review' && onReject && (
|
||||||
|
<button onClick={() => { const c = prompt('Ablehnungsgrund:'); if (c) onReject(c) }} disabled={saving}
|
||||||
|
className="px-3 py-1.5 bg-red-500 text-white rounded-lg text-sm hover:bg-red-600 disabled:opacity-50">
|
||||||
|
Ablehnen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{/* Publish — only for approved */}
|
||||||
|
{version && version.status === 'approved' && (
|
||||||
|
<button onClick={onPublish} disabled={saving}
|
||||||
|
className="px-3 py-1.5 bg-green-600 text-white rounded-lg text-sm hover:bg-green-700 disabled:opacity-50">
|
||||||
Publizieren
|
Publizieren
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{/* Preview + Test — always when version exists */}
|
||||||
{version && (
|
{version && (
|
||||||
<button
|
<>
|
||||||
onClick={onPreview}
|
<button onClick={onPreview}
|
||||||
className="px-3 py-1.5 border border-gray-300 text-gray-700 rounded-lg text-sm hover:bg-gray-50"
|
className="px-3 py-1.5 border border-gray-300 text-gray-700 rounded-lg text-sm hover:bg-gray-50">
|
||||||
>
|
Vorschau
|
||||||
Vorschau
|
</button>
|
||||||
</button>
|
{onSendTest && (
|
||||||
|
<button onClick={() => { const e = prompt('Test-E-Mail an:'); if (e) onSendTest(e) }}
|
||||||
|
className="px-3 py-1.5 border border-blue-300 text-blue-700 rounded-lg text-sm hover:bg-blue-50">
|
||||||
|
Test senden
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -77,7 +108,7 @@ export function EditorTab({
|
|||||||
{/* Variables */}
|
{/* Variables */}
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
<span className="text-xs text-gray-500 mr-1">Variablen:</span>
|
<span className="text-xs text-gray-500 mr-1">Variablen:</span>
|
||||||
{(template.variables || []).map(v => (
|
{(Array.isArray(template.variables) ? template.variables : []).map(v => (
|
||||||
<button
|
<button
|
||||||
key={v}
|
key={v}
|
||||||
onClick={() => onHtmlChange(html + `{{${v}}}`)}
|
onClick={() => onHtmlChange(html + `{{${v}}}`)}
|
||||||
|
|||||||
@@ -30,12 +30,12 @@ export function TemplateCard({ template, onEdit }: TemplateCardProps) {
|
|||||||
<p className="text-xs text-gray-500 mt-2 line-clamp-2">{template.description}</p>
|
<p className="text-xs text-gray-500 mt-2 line-clamp-2">{template.description}</p>
|
||||||
)}
|
)}
|
||||||
<div className="mt-3 flex flex-wrap gap-1">
|
<div className="mt-3 flex flex-wrap gap-1">
|
||||||
{(template.variables || []).slice(0, 4).map(v => (
|
{(Array.isArray(template.variables) ? template.variables : []).slice(0, 4).map(v => (
|
||||||
<span key={v} className="px-1.5 py-0.5 bg-gray-50 text-gray-500 rounded text-xs font-mono">
|
<span key={v} className="px-1.5 py-0.5 bg-gray-50 text-gray-500 rounded text-xs font-mono">
|
||||||
{`{{${v}}}`}
|
{`{{${v}}}`}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
{(template.variables || []).length > 4 && (
|
{Array.isArray(template.variables) && template.variables.length > 4 && (
|
||||||
<span className="text-xs text-gray-400">+{template.variables.length - 4}</span>
|
<span className="text-xs text-gray-400">+{template.variables.length - 4}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
SendLog,
|
SendLog,
|
||||||
Settings,
|
Settings,
|
||||||
TabId,
|
TabId,
|
||||||
|
TemplateApproval,
|
||||||
TemplateType,
|
TemplateType,
|
||||||
TemplateVersion,
|
TemplateVersion,
|
||||||
getHeaders,
|
getHeaders,
|
||||||
@@ -194,6 +195,72 @@ export function useEmailTemplates(activeTab: TabId) {
|
|||||||
}
|
}
|
||||||
}, [settingsForm])
|
}, [settingsForm])
|
||||||
|
|
||||||
|
// Workflow actions
|
||||||
|
const submitForReview = useCallback(async () => {
|
||||||
|
if (!editorVersion) return
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/versions/${editorVersion.id}/submit`, {
|
||||||
|
method: 'POST', headers: getHeaders(),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
const updated = await res.json()
|
||||||
|
setEditorVersion(updated)
|
||||||
|
await loadTemplates()
|
||||||
|
} catch (e: any) { setError(e.message) } finally { setSaving(false) }
|
||||||
|
}, [editorVersion, loadTemplates])
|
||||||
|
|
||||||
|
const approveVersion = useCallback(async (comment?: string) => {
|
||||||
|
if (!editorVersion) return
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/versions/${editorVersion.id}/approve`, {
|
||||||
|
method: 'POST', headers: getHeaders(),
|
||||||
|
body: JSON.stringify({ comment: comment || '' }),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
const updated = await res.json()
|
||||||
|
setEditorVersion(updated)
|
||||||
|
await loadTemplates()
|
||||||
|
} catch (e: any) { setError(e.message) } finally { setSaving(false) }
|
||||||
|
}, [editorVersion, loadTemplates])
|
||||||
|
|
||||||
|
const rejectVersion = useCallback(async (comment: string) => {
|
||||||
|
if (!editorVersion) return
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/versions/${editorVersion.id}/reject`, {
|
||||||
|
method: 'POST', headers: getHeaders(),
|
||||||
|
body: JSON.stringify({ comment }),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
const updated = await res.json()
|
||||||
|
setEditorVersion(updated)
|
||||||
|
await loadTemplates()
|
||||||
|
} catch (e: any) { setError(e.message) } finally { setSaving(false) }
|
||||||
|
}, [editorVersion, loadTemplates])
|
||||||
|
|
||||||
|
const sendTestEmail = useCallback(async (recipientEmail: string) => {
|
||||||
|
if (!editorVersion) return
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/versions/${editorVersion.id}/send-test`, {
|
||||||
|
method: 'POST', headers: getHeaders(),
|
||||||
|
body: JSON.stringify({ recipient: recipientEmail }),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
return await res.json()
|
||||||
|
} catch (e: any) { setError(e.message) }
|
||||||
|
}, [editorVersion])
|
||||||
|
|
||||||
|
const loadApprovalHistory = useCallback(async (versionId: string): Promise<TemplateApproval[]> => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/versions/${versionId}/approvals`, { headers: getHeaders() })
|
||||||
|
if (!res.ok) return []
|
||||||
|
const data = await res.json()
|
||||||
|
return Array.isArray(data) ? data : data.approvals || []
|
||||||
|
} catch { return [] }
|
||||||
|
}, [])
|
||||||
|
|
||||||
const initializeDefaults = useCallback(async () => {
|
const initializeDefaults = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE}/initialize`, {
|
const res = await fetch(`${API_BASE}/initialize`, {
|
||||||
@@ -222,6 +289,8 @@ export function useEmailTemplates(activeTab: TabId) {
|
|||||||
setSettingsForm,
|
setSettingsForm,
|
||||||
// Actions
|
// Actions
|
||||||
openEditor, saveVersion, publishVersion, loadPreview,
|
openEditor, saveVersion, publishVersion, loadPreview,
|
||||||
|
submitForReview, approveVersion, rejectVersion,
|
||||||
|
sendTestEmail, loadApprovalHistory,
|
||||||
saveSettings2, initializeDefaults,
|
saveSettings2, initializeDefaults,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,16 @@ export interface SendLog {
|
|||||||
sent_at: string | null
|
sent_at: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TemplateApproval {
|
||||||
|
id: string
|
||||||
|
version_id: string
|
||||||
|
action: string // submitted, approved, rejected
|
||||||
|
actor_id: string
|
||||||
|
actor_name?: string
|
||||||
|
comment?: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface Settings {
|
export interface Settings {
|
||||||
sender_name: string
|
sender_name: string
|
||||||
sender_email: string
|
sender_email: string
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ export default function EmailTemplatesPage() {
|
|||||||
setEditorSubject, setEditorHtml,
|
setEditorSubject, setEditorHtml,
|
||||||
setSettingsForm,
|
setSettingsForm,
|
||||||
openEditor, saveVersion, publishVersion, loadPreview,
|
openEditor, saveVersion, publishVersion, loadPreview,
|
||||||
|
submitForReview, approveVersion, rejectVersion,
|
||||||
|
sendTestEmail, loadApprovalHistory,
|
||||||
saveSettings2, initializeDefaults,
|
saveSettings2, initializeDefaults,
|
||||||
} = useEmailTemplates(activeTab)
|
} = useEmailTemplates(activeTab)
|
||||||
|
|
||||||
@@ -68,6 +70,10 @@ export default function EmailTemplatesPage() {
|
|||||||
onPublish={publishVersion}
|
onPublish={publishVersion}
|
||||||
onPreview={loadPreview}
|
onPreview={loadPreview}
|
||||||
onBack={() => setActiveTab('templates')}
|
onBack={() => setActiveTab('templates')}
|
||||||
|
onSubmitForReview={submitForReview}
|
||||||
|
onApprove={approveVersion}
|
||||||
|
onReject={rejectVersion}
|
||||||
|
onSendTest={sendTestEmail}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,258 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useRef, useCallback } from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { usePathname, useParams } from 'next/navigation'
|
||||||
|
|
||||||
|
interface CEStep {
|
||||||
|
step: number
|
||||||
|
label: string
|
||||||
|
href: string | null
|
||||||
|
external?: boolean
|
||||||
|
sameAs?: number
|
||||||
|
note?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const CE_STEPS: CEStep[] = [
|
||||||
|
{ step: 3, label: 'Grenzen & Verwendung', href: '/interview' },
|
||||||
|
{ step: 4, label: 'Normenrecherche', href: null, external: true },
|
||||||
|
{ step: 5, label: 'Komponenten', href: '/components' },
|
||||||
|
{ step: 6, label: 'Gefaehrdungen', href: '/hazards' },
|
||||||
|
{ step: 7, label: 'Risikobewertung', href: '/hazards', sameAs: 6 },
|
||||||
|
{ step: 8, label: 'Massnahmen', href: '/mitigations' },
|
||||||
|
{ step: 9, label: 'Nachweise', href: '/evidence' },
|
||||||
|
{ step: 10, label: 'Restrisiko', href: '/hazards', note: 'Reassessment' },
|
||||||
|
{ step: 11, label: 'Verifikation', href: '/verification' },
|
||||||
|
{ step: 14, label: 'CE-Akte', href: '/tech-file' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function getNavigableSteps(basePath: string): CEStep[] {
|
||||||
|
return CE_STEPS.filter((s) => s.href !== null && !s.external)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function IACEFlowFAB() {
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
const panelRef = useRef<HTMLDivElement>(null)
|
||||||
|
const fabRef = useRef<HTMLButtonElement>(null)
|
||||||
|
const pathname = usePathname()
|
||||||
|
const params = useParams()
|
||||||
|
const projectId = params?.projectId as string
|
||||||
|
|
||||||
|
const basePath = `/sdk/iace/${projectId}`
|
||||||
|
|
||||||
|
const activeStepIndex = CE_STEPS.findIndex((s) => {
|
||||||
|
if (!s.href) return false
|
||||||
|
return pathname.startsWith(`${basePath}${s.href}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
const navigableSteps = getNavigableSteps(basePath)
|
||||||
|
const currentNavIndex = navigableSteps.findIndex((s) => {
|
||||||
|
if (!s.href) return false
|
||||||
|
return pathname.startsWith(`${basePath}${s.href}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
const completedCount = CE_STEPS.filter((s) => s.href && !s.external).length
|
||||||
|
const totalSteps = CE_STEPS.length
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => setIsOpen(false), [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') handleClose()
|
||||||
|
}
|
||||||
|
function onClickOutside(e: MouseEvent) {
|
||||||
|
if (
|
||||||
|
panelRef.current &&
|
||||||
|
!panelRef.current.contains(e.target as Node) &&
|
||||||
|
fabRef.current &&
|
||||||
|
!fabRef.current.contains(e.target as Node)
|
||||||
|
) {
|
||||||
|
handleClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isOpen) {
|
||||||
|
document.addEventListener('keydown', onKeyDown)
|
||||||
|
document.addEventListener('mousedown', onClickOutside)
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', onKeyDown)
|
||||||
|
document.removeEventListener('mousedown', onClickOutside)
|
||||||
|
}
|
||||||
|
}, [isOpen, handleClose])
|
||||||
|
|
||||||
|
const goPrev = () => {
|
||||||
|
if (currentNavIndex > 0) {
|
||||||
|
const prev = navigableSteps[currentNavIndex - 1]
|
||||||
|
if (prev.href) window.location.href = `${basePath}${prev.href}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const goNext = () => {
|
||||||
|
if (currentNavIndex < navigableSteps.length - 1) {
|
||||||
|
const next = navigableSteps[currentNavIndex + 1]
|
||||||
|
if (next.href) window.location.href = `${basePath}${next.href}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-6 right-6 z-50 flex flex-col items-end">
|
||||||
|
{/* Expanded Panel */}
|
||||||
|
<div
|
||||||
|
ref={panelRef}
|
||||||
|
className={`mb-3 w-[300px] max-h-[70vh] overflow-y-auto bg-white dark:bg-gray-800 rounded-xl shadow-2xl border border-gray-200 dark:border-gray-700 transition-all duration-200 origin-bottom-right ${
|
||||||
|
isOpen
|
||||||
|
? 'opacity-100 scale-100 translate-y-0'
|
||||||
|
: 'opacity-0 scale-95 translate-y-2 pointer-events-none'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="sticky top-0 bg-white dark:bg-gray-800 px-4 py-3 border-b border-gray-100 dark:border-gray-700">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
|
CE-Prozessschritte
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-gray-500 mt-0.5">
|
||||||
|
{completedCount}/{totalSteps} Schritte im Tool
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Steps */}
|
||||||
|
<div className="py-2 px-2">
|
||||||
|
{CE_STEPS.map((step, idx) => {
|
||||||
|
const isActive = idx === activeStepIndex
|
||||||
|
const isExternal = step.external || step.href === null
|
||||||
|
const fullHref = step.href ? `${basePath}${step.href}` : null
|
||||||
|
|
||||||
|
const rowContent = (
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition-colors ${
|
||||||
|
isActive
|
||||||
|
? 'bg-purple-50 dark:bg-purple-900/40'
|
||||||
|
: isExternal
|
||||||
|
? 'opacity-50 cursor-default'
|
||||||
|
: 'hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Step number circle */}
|
||||||
|
<div
|
||||||
|
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0 ${
|
||||||
|
isActive
|
||||||
|
? 'bg-purple-600 text-white'
|
||||||
|
: isExternal
|
||||||
|
? 'bg-gray-200 dark:bg-gray-600 text-gray-400 dark:text-gray-500'
|
||||||
|
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isActive ? (
|
||||||
|
<span className="w-2 h-2 rounded-full bg-white" />
|
||||||
|
) : !isExternal ? (
|
||||||
|
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
step.step
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Label */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<span
|
||||||
|
className={`block truncate font-medium ${
|
||||||
|
isActive
|
||||||
|
? 'text-purple-700 dark:text-purple-300'
|
||||||
|
: isExternal
|
||||||
|
? 'text-gray-400 dark:text-gray-500'
|
||||||
|
: 'text-gray-700 dark:text-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{step.label}
|
||||||
|
</span>
|
||||||
|
{(step.note || isExternal) && (
|
||||||
|
<span className="text-[10px] text-gray-400">
|
||||||
|
{step.note || '(extern)'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step badge */}
|
||||||
|
<span className="text-[10px] text-gray-400 flex-shrink-0">
|
||||||
|
#{step.step}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (fullHref && !isExternal) {
|
||||||
|
return (
|
||||||
|
<Link key={idx} href={fullHref} onClick={handleClose}>
|
||||||
|
{rowContent}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return <div key={idx}>{rowContent}</div>
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Prev/Next navigation */}
|
||||||
|
<div className="sticky bottom-0 bg-white dark:bg-gray-800 border-t border-gray-100 dark:border-gray-700 px-4 py-2.5 flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
onClick={goPrev}
|
||||||
|
disabled={currentNavIndex <= 0}
|
||||||
|
className="flex items-center gap-1 text-xs font-medium text-purple-600 hover:text-purple-700 disabled:text-gray-300 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
Zurueck
|
||||||
|
</button>
|
||||||
|
<span className="text-[10px] text-gray-400">
|
||||||
|
{currentNavIndex >= 0 ? currentNavIndex + 1 : '-'}/{navigableSteps.length}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={goNext}
|
||||||
|
disabled={currentNavIndex >= navigableSteps.length - 1 || currentNavIndex < 0}
|
||||||
|
className="flex items-center gap-1 text-xs font-medium text-purple-600 hover:text-purple-700 disabled:text-gray-300 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
Weiter
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* FAB Button */}
|
||||||
|
<button
|
||||||
|
ref={fabRef}
|
||||||
|
onClick={() => setIsOpen((o) => !o)}
|
||||||
|
className="w-14 h-14 rounded-full bg-gradient-to-br from-purple-600 to-indigo-600 text-white shadow-lg hover:shadow-xl hover:scale-105 active:scale-95 transition-all flex items-center justify-center"
|
||||||
|
title="CE-Prozessschritte"
|
||||||
|
>
|
||||||
|
{/* Steps/flow icon */}
|
||||||
|
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<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>
|
||||||
|
{/* Progress ring */}
|
||||||
|
<svg className="absolute w-14 h-14" viewBox="0 0 56 56">
|
||||||
|
<circle
|
||||||
|
cx="28"
|
||||||
|
cy="28"
|
||||||
|
r="25"
|
||||||
|
fill="none"
|
||||||
|
stroke="rgba(255,255,255,0.2)"
|
||||||
|
strokeWidth="3"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx="28"
|
||||||
|
cy="28"
|
||||||
|
r="25"
|
||||||
|
fill="none"
|
||||||
|
stroke="white"
|
||||||
|
strokeWidth="3"
|
||||||
|
strokeDasharray={`${(completedCount / totalSteps) * 157} 157`}
|
||||||
|
strokeLinecap="round"
|
||||||
|
transform="rotate(-90 28 28)"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
|
||||||
|
interface NormRef {
|
||||||
|
id: string
|
||||||
|
number: string
|
||||||
|
title_de: string
|
||||||
|
norm_type: string
|
||||||
|
scope_de: string
|
||||||
|
mandatory: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NormSuggestion {
|
||||||
|
norm: NormRef
|
||||||
|
reason: string
|
||||||
|
confidence: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NormResult {
|
||||||
|
a_norms: NormSuggestion[]
|
||||||
|
b1_norms: NormSuggestion[]
|
||||||
|
b2_norms: NormSuggestion[]
|
||||||
|
c_norms: NormSuggestion[]
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const TYPE_CONFIG: Record<string, { label: string; color: string; desc: string }> = {
|
||||||
|
a_norms: { label: 'A-Normen', color: 'border-red-200 bg-red-50 text-red-800', desc: 'Grundnormen (immer anwendbar)' },
|
||||||
|
b1_norms: { label: 'B1-Normen', color: 'border-blue-200 bg-blue-50 text-blue-800', desc: 'Sicherheitsgrundnormen' },
|
||||||
|
b2_norms: { label: 'B2-Normen', color: 'border-green-200 bg-green-50 text-green-800', desc: 'Sicherheitsfachgrundnormen' },
|
||||||
|
c_norms: { label: 'C-Normen', color: 'border-purple-200 bg-purple-50 text-purple-800', desc: 'Maschinenspezifische Normen' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SuggestedNorms({ projectId }: { projectId: string }) {
|
||||||
|
const [data, setData] = useState<NormResult | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [collapsed, setCollapsed] = useState(false)
|
||||||
|
const [customNorms, setCustomNorms] = useState<Array<{ number: string; title: string }>>([])
|
||||||
|
const [customNormNumber, setCustomNormNumber] = useState('')
|
||||||
|
const [customNormTitle, setCustomNormTitle] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`/api/sdk/v1/iace/projects/${projectId}/suggested-norms`)
|
||||||
|
.then((r) => r.ok ? r.json() : null)
|
||||||
|
.then((json) => {
|
||||||
|
if (json?.suggestions) setData(json.suggestions)
|
||||||
|
else if (json?.a_norms !== undefined) setData(json)
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [projectId])
|
||||||
|
|
||||||
|
if (loading) return null
|
||||||
|
if (!data || data.total === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
||||||
|
<button
|
||||||
|
onClick={() => setCollapsed(!collapsed)}
|
||||||
|
className="w-full flex items-center justify-between p-6 text-left"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-amber-50 rounded-lg flex items-center justify-center">
|
||||||
|
<svg className="w-5 h-5 text-amber-600" 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>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
|
Normenrecherche — {data.total} relevante Normen
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Automatisch ermittelt aus Maschinentyp, Gefaehrdungen und Komponenten
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-8 h-8 flex items-center justify-center rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors flex-shrink-0">
|
||||||
|
<svg className={`w-5 h-5 text-gray-400 transition-transform ${collapsed ? '' : 'rotate-180'}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{!collapsed && (
|
||||||
|
<div className="px-6 pb-6 space-y-4">
|
||||||
|
{/* Legend */}
|
||||||
|
<div className="flex flex-wrap gap-2 text-xs">
|
||||||
|
{Object.entries(TYPE_CONFIG).map(([key, cfg]) => (
|
||||||
|
<span key={key} className={`px-2 py-0.5 rounded border ${cfg.color}`}>{cfg.label}: {cfg.desc}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Norm groups */}
|
||||||
|
{(['a_norms', 'b1_norms', 'b2_norms', 'c_norms'] as const).map((type) => {
|
||||||
|
const norms = data[type]
|
||||||
|
if (!norms || norms.length === 0) return null
|
||||||
|
const cfg = TYPE_CONFIG[type]
|
||||||
|
return (
|
||||||
|
<div key={type}>
|
||||||
|
<h3 className={`text-xs font-semibold px-2 py-1 rounded inline-block mb-2 border ${cfg.color}`}>
|
||||||
|
{cfg.label} ({norms.length})
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{norms.map((s) => (
|
||||||
|
<div key={s.norm.id} className="flex items-start gap-3 p-3 rounded-lg bg-gray-50 dark:bg-gray-700/50 border border-gray-100 dark:border-gray-600">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-mono font-semibold text-gray-900 dark:text-white">
|
||||||
|
{s.norm.number}
|
||||||
|
</span>
|
||||||
|
{s.norm.mandatory && (
|
||||||
|
<span className="px-1.5 py-0.5 text-xs font-medium bg-red-100 text-red-700 rounded">
|
||||||
|
Pflicht
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="px-1.5 py-0.5 text-xs bg-gray-200 text-gray-600 rounded">
|
||||||
|
{Math.round(s.confidence * 100)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-700 dark:text-gray-300 mt-0.5">{s.norm.title_de}</p>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">{s.norm.scope_de}</p>
|
||||||
|
<p className="text-xs text-amber-600 mt-1">
|
||||||
|
Grund: {s.reason}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Add custom norm */}
|
||||||
|
<div className="p-3 rounded-lg bg-gray-50 dark:bg-gray-700/50 border border-gray-200 dark:border-gray-600">
|
||||||
|
<p className="text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">Weitere Norm ergaenzen</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text" placeholder="z.B. ISO 13857:2019"
|
||||||
|
value={customNormNumber} onChange={(e) => setCustomNormNumber(e.target.value)}
|
||||||
|
className="flex-1 px-3 py-1.5 text-xs border border-gray-300 rounded-lg focus:ring-1 focus:ring-purple-400 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text" placeholder="Titel (optional)"
|
||||||
|
value={customNormTitle} onChange={(e) => setCustomNormTitle(e.target.value)}
|
||||||
|
className="flex-1 px-3 py-1.5 text-xs border border-gray-300 rounded-lg focus:ring-1 focus:ring-purple-400 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (customNormNumber.trim()) {
|
||||||
|
setCustomNorms((prev) => [...prev, { number: customNormNumber.trim(), title: customNormTitle.trim() }])
|
||||||
|
setCustomNormNumber('')
|
||||||
|
setCustomNormTitle('')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={!customNormNumber.trim()}
|
||||||
|
className="px-3 py-1.5 text-xs bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
+ Hinzufuegen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{customNorms.length > 0 && (
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
{customNorms.map((cn, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2 text-xs">
|
||||||
|
<span className="font-mono font-semibold text-gray-800 dark:text-gray-200">{cn.number}</span>
|
||||||
|
{cn.title && <span className="text-gray-500">— {cn.title}</span>}
|
||||||
|
<span className="px-1.5 py-0.5 bg-blue-100 text-blue-700 rounded text-xs">Manuell</span>
|
||||||
|
<button onClick={() => setCustomNorms((prev) => prev.filter((_, j) => j !== i))} className="text-red-400 hover:text-red-600">
|
||||||
|
<svg className="w-3.5 h-3.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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pflicht-Erklärung + Disclaimer */}
|
||||||
|
<div className="space-y-2 text-xs">
|
||||||
|
<div className="p-3 rounded-lg bg-red-50 border border-red-200 text-red-800">
|
||||||
|
<strong>Pflicht</strong> bedeutet: Diese Norm ist fuer diesen Maschinentyp typischerweise zwingend anzuwenden
|
||||||
|
(z.B. ISO 12100 fuer alle Maschinen, EN 692 fuer mechanische Pressen). Die Anwendung harmonisierter Normen
|
||||||
|
erzeugt eine Konformitaetsvermutung.
|
||||||
|
</div>
|
||||||
|
<div className="p-3 rounded-lg bg-amber-50 border border-amber-200 text-amber-800">
|
||||||
|
<strong>Hinweis:</strong> Diese Normenvorschlaege basieren auf dem Maschinentyp und den identifizierten
|
||||||
|
Gefaehrdungen. Der CE-Fachmann muss die Anwendbarkeit pruefen und ggf. weitere Normen ueber das Feld oben ergaenzen.
|
||||||
|
Normtexte muessen separat beschafft werden (z.B. ueber <a href="https://www.beuth.de" target="_blank" rel="noopener noreferrer" className="underline font-medium">beuth.de</a>).
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -55,9 +55,9 @@ export default function ComponentsPage() {
|
|||||||
Erfassen Sie alle Software-, Firmware-, KI- und Hardware-Komponenten der Maschine.
|
Erfassen Sie alle Software-, Firmware-, KI- und Hardware-Komponenten der Maschine.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{!c.showForm && (
|
{!showForm && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button onClick={() => c.setShowLibrary(true)}
|
<button onClick={() => setShowLibrary(true)}
|
||||||
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 text-sm">
|
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 text-sm">
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<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" />
|
<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" />
|
||||||
@@ -96,7 +96,7 @@ export default function ComponentsPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{c.tree.length > 0 ? (
|
{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="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="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">
|
<div className="flex items-center gap-2 text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
@@ -108,14 +108,14 @@ export default function ComponentsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="py-1">
|
<div className="py-1">
|
||||||
{c.tree.map((component) => (
|
{tree.map((component) => (
|
||||||
<ComponentTreeNode key={component.id} component={component} depth={0}
|
<ComponentTreeNode key={component.id} component={component} depth={0}
|
||||||
onEdit={c.handleEdit} onDelete={c.handleDelete} onAddChild={c.handleAddChild} />
|
onEdit={handleEdit} onDelete={handleDelete} onAddChild={handleAddChild} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
!c.showForm && (
|
!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="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">
|
<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">
|
<svg className="w-8 h-8 text-purple-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
@@ -128,11 +128,11 @@ export default function ComponentsPage() {
|
|||||||
Erstellen Sie eine hierarchische Struktur aus Software, Firmware, KI-Modulen und Hardware.
|
Erstellen Sie eine hierarchische Struktur aus Software, Firmware, KI-Modulen und Hardware.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-6 flex items-center justify-center gap-3">
|
<div className="mt-6 flex items-center justify-center gap-3">
|
||||||
<button onClick={() => c.setShowLibrary(true)}
|
<button onClick={() => setShowLibrary(true)}
|
||||||
className="px-6 py-3 border border-purple-300 text-purple-700 rounded-lg hover:bg-purple-50 transition-colors">
|
className="px-6 py-3 border border-purple-300 text-purple-700 rounded-lg hover:bg-purple-50 transition-colors">
|
||||||
Aus Bibliothek waehlen
|
Aus Bibliothek waehlen
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => c.setShowForm(true)}
|
<button onClick={() => setShowForm(true)}
|
||||||
className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors">
|
className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors">
|
||||||
Manuell hinzufuegen
|
Manuell hinzufuegen
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -11,13 +11,13 @@ export function AutoSuggestPanel({ matchResult, applying, onApply, onClose }: {
|
|||||||
onClose: () => void
|
onClose: () => void
|
||||||
}) {
|
}) {
|
||||||
const [selectedHazards, setSelectedHazards] = useState<Set<string>>(
|
const [selectedHazards, setSelectedHazards] = useState<Set<string>>(
|
||||||
new Set(matchResult.suggested_hazards.map(h => h.category))
|
new Set((matchResult.suggested_hazards || []).map(h => h.category))
|
||||||
)
|
)
|
||||||
const [selectedMeasures, setSelectedMeasures] = useState<Set<string>>(
|
const [selectedMeasures, setSelectedMeasures] = useState<Set<string>>(
|
||||||
new Set(matchResult.suggested_measures.map(m => m.measure_id))
|
new Set((matchResult.suggested_measures || []).map(m => m.measure_id))
|
||||||
)
|
)
|
||||||
const [selectedEvidence, setSelectedEvidence] = useState<Set<string>>(
|
const [selectedEvidence, setSelectedEvidence] = useState<Set<string>>(
|
||||||
new Set(matchResult.suggested_evidence.map(e => e.evidence_id))
|
new Set((matchResult.suggested_evidence || []).map(e => e.evidence_id))
|
||||||
)
|
)
|
||||||
|
|
||||||
function toggle<T>(set: Set<T>, setSet: (s: Set<T>) => void, key: T) {
|
function toggle<T>(set: Set<T>, setSet: (s: Set<T>) => void, key: T) {
|
||||||
|
|||||||
+274
@@ -0,0 +1,274 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import {
|
||||||
|
Hazard, CATEGORY_LABELS, getRiskColor, getRiskLevelLabel, getRiskLevelISO,
|
||||||
|
} from './types'
|
||||||
|
|
||||||
|
interface RiskAssessmentTableProps {
|
||||||
|
projectId: string
|
||||||
|
hazards: Hazard[]
|
||||||
|
onReassess?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Editable S/E/P/A state per hazard for the "after measures" column. */
|
||||||
|
interface EditState {
|
||||||
|
severity: number; exposure: number; probability: number; avoidance: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function rpz(s: number, e: number, p: number, a: number): number {
|
||||||
|
return a >= 1 ? s * e * p * a : s * e * p
|
||||||
|
}
|
||||||
|
|
||||||
|
function plFromRpz(r: number): string {
|
||||||
|
if (r > 300) return 'e'
|
||||||
|
if (r >= 151) return 'd'
|
||||||
|
if (r >= 61) return 'c'
|
||||||
|
if (r >= 21) return 'b'
|
||||||
|
return 'a'
|
||||||
|
}
|
||||||
|
|
||||||
|
function silFromRpz(r: number): number {
|
||||||
|
if (r > 300) return 3
|
||||||
|
if (r >= 151) return 2
|
||||||
|
if (r >= 61) return 1
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const PL_COLORS: Record<string, string> = {
|
||||||
|
e: 'bg-red-100 text-red-800', d: 'bg-orange-100 text-orange-800',
|
||||||
|
c: 'bg-yellow-100 text-yellow-800', b: 'bg-green-100 text-green-800',
|
||||||
|
a: 'bg-gray-100 text-gray-600',
|
||||||
|
}
|
||||||
|
|
||||||
|
const SIL_COLORS: Record<number, string> = {
|
||||||
|
3: 'bg-red-100 text-red-800', 2: 'bg-orange-100 text-orange-800',
|
||||||
|
1: 'bg-yellow-100 text-yellow-800', 0: 'bg-gray-100 text-gray-600',
|
||||||
|
}
|
||||||
|
|
||||||
|
const VALUES = [1, 2, 3, 4, 5]
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Inline editable dropdown
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function InlineSelect({ value, onChange, label }: {
|
||||||
|
value: number; onChange: (v: number) => void; label: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<select value={value} onChange={e => onChange(Number(e.target.value))}
|
||||||
|
aria-label={label}
|
||||||
|
className="w-12 text-center text-xs border border-gray-300 rounded bg-white dark:bg-gray-700 dark:border-gray-600 dark:text-white py-0.5 focus:ring-1 focus:ring-purple-400 focus:border-purple-400">
|
||||||
|
{VALUES.map(v => <option key={v} value={v}>{v}</option>)}
|
||||||
|
</select>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function RiskAssessmentTable({ projectId, hazards, onReassess }: RiskAssessmentTableProps) {
|
||||||
|
const [mitCounts, setMitCounts] = useState<Record<string, number>>({})
|
||||||
|
const [edits, setEdits] = useState<Record<string, EditState>>({})
|
||||||
|
const [saving, setSaving] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Fetch mitigation counts per hazard
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations`)
|
||||||
|
if (!res.ok) return
|
||||||
|
const json = await res.json()
|
||||||
|
const mits: { hazard_id: string }[] = json.mitigations || json || []
|
||||||
|
const counts: Record<string, number> = {}
|
||||||
|
for (const m of mits) {
|
||||||
|
counts[m.hazard_id] = (counts[m.hazard_id] || 0) + 1
|
||||||
|
}
|
||||||
|
setMitCounts(counts)
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
})()
|
||||||
|
}, [projectId])
|
||||||
|
|
||||||
|
// Initialise edit state from hazard defaults
|
||||||
|
useEffect(() => {
|
||||||
|
const init: Record<string, EditState> = {}
|
||||||
|
for (const h of hazards) {
|
||||||
|
if (!edits[h.id]) {
|
||||||
|
// Read from risk_assessment if available (enriched response), fallback to hazard fields
|
||||||
|
const ra = (h as Record<string, unknown>).risk_assessment as Record<string, number> | null
|
||||||
|
init[h.id] = {
|
||||||
|
severity: ra?.severity || h.severity || 3,
|
||||||
|
exposure: ra?.exposure || h.exposure || 3,
|
||||||
|
probability: ra?.probability || h.probability || 3,
|
||||||
|
avoidance: h.avoidance || 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.keys(init).length > 0) setEdits(prev => ({ ...prev, ...init }))
|
||||||
|
}, [hazards]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const updateEdit = useCallback((id: string, field: keyof EditState, value: number) => {
|
||||||
|
setEdits(prev => ({ ...prev, [id]: { ...prev[id], [field]: value } }))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function handleReassess(hazardId: string) {
|
||||||
|
const e = edits[hazardId]
|
||||||
|
if (!e) return
|
||||||
|
setSaving(hazardId)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/hazards/${hazardId}/reassess`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
hazard_id: hazardId, severity: e.severity, exposure: e.exposure,
|
||||||
|
probability: e.probability, avoidance: e.avoidance,
|
||||||
|
control_maturity: 3, control_coverage: 0.5, test_evidence_strength: 0.5,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (res.ok) onReassess?.()
|
||||||
|
} catch (err) { console.error('Reassess failed:', err) }
|
||||||
|
finally { setSaving(null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = [...hazards].sort((a, b) => (b.r_inherent || 0) - (a.r_inherent || 0))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
|
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||||
|
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">Risikobewertungstabelle (ISO 12100)</h2>
|
||||||
|
<span className="text-xs text-gray-500">{hazards.length} Gefaehrdungen</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-xs whitespace-nowrap">
|
||||||
|
<thead>
|
||||||
|
{/* Group header */}
|
||||||
|
<tr className="bg-gray-100 dark:bg-gray-750 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<th colSpan={2} className="px-3 py-1.5 text-left font-semibold text-gray-700 dark:text-gray-300 border-r border-gray-200 dark:border-gray-600">Gefaehrdung</th>
|
||||||
|
<th colSpan={5} className="px-3 py-1.5 text-center font-semibold text-gray-700 dark:text-gray-300 border-r border-gray-200 dark:border-gray-600">Erstbewertung</th>
|
||||||
|
<th colSpan={6} className="px-3 py-1.5 text-center font-semibold text-purple-700 dark:text-purple-400 border-r border-gray-200 dark:border-gray-600">Nach Massnahmen (editierbar)</th>
|
||||||
|
<th colSpan={2} className="px-3 py-1.5 text-center font-semibold text-gray-700 dark:text-gray-300 border-r border-gray-200 dark:border-gray-600">SIL / PL</th>
|
||||||
|
<th colSpan={2} className="px-3 py-1.5 text-center font-semibold text-gray-700 dark:text-gray-300">Status</th>
|
||||||
|
</tr>
|
||||||
|
{/* Column header */}
|
||||||
|
<tr className="bg-gray-50 dark:bg-gray-750 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<th className="px-3 py-2 text-left font-medium text-gray-500 uppercase tracking-wider">Bezeichnung</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium text-gray-500 uppercase tracking-wider border-r border-gray-200 dark:border-gray-600">Kategorie</th>
|
||||||
|
{/* Initial */}
|
||||||
|
<th className="px-2 py-2 text-center font-medium text-gray-500">S</th>
|
||||||
|
<th className="px-2 py-2 text-center font-medium text-gray-500">E</th>
|
||||||
|
<th className="px-2 py-2 text-center font-medium text-gray-500">P</th>
|
||||||
|
<th className="px-2 py-2 text-center font-medium text-gray-500">RPZ</th>
|
||||||
|
<th className="px-2 py-2 text-center font-medium text-gray-500 border-r border-gray-200 dark:border-gray-600">Risiko</th>
|
||||||
|
{/* After */}
|
||||||
|
<th className="px-2 py-2 text-center font-medium text-purple-600">S</th>
|
||||||
|
<th className="px-2 py-2 text-center font-medium text-purple-600">E</th>
|
||||||
|
<th className="px-2 py-2 text-center font-medium text-purple-600">P</th>
|
||||||
|
<th className="px-2 py-2 text-center font-medium text-purple-600">RPZ</th>
|
||||||
|
<th className="px-2 py-2 text-center font-medium text-purple-600">Risiko</th>
|
||||||
|
<th className="px-2 py-2 text-center font-medium text-purple-600 border-r border-gray-200 dark:border-gray-600"></th>
|
||||||
|
{/* SIL/PL */}
|
||||||
|
<th className="px-2 py-2 text-center font-medium text-gray-500">SIL</th>
|
||||||
|
<th className="px-2 py-2 text-center font-medium text-gray-500 border-r border-gray-200 dark:border-gray-600">PL</th>
|
||||||
|
{/* Status */}
|
||||||
|
<th className="px-2 py-2 text-center font-medium text-gray-500">Massn.</th>
|
||||||
|
<th className="px-2 py-2 text-center font-medium text-gray-500">Akzeptabel</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||||
|
{sorted.map(h => {
|
||||||
|
const e = edits[h.id]
|
||||||
|
const initRpz = h.r_inherent || rpz(h.severity, h.exposure, h.probability, h.avoidance)
|
||||||
|
const afterRpz = e ? rpz(e.severity, e.exposure, e.probability, e.avoidance) : initRpz
|
||||||
|
const afterLevel = getRiskLevelISO(afterRpz)
|
||||||
|
const sil = silFromRpz(afterRpz)
|
||||||
|
const pl = plFromRpz(afterRpz)
|
||||||
|
const mc = mitCounts[h.id] || 0
|
||||||
|
const changed = e && (e.severity !== h.severity || e.exposure !== h.exposure || e.probability !== h.probability || e.avoidance !== (h.avoidance || 3))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={h.id} className="hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors">
|
||||||
|
{/* Hazard info */}
|
||||||
|
<td className="px-3 py-2 max-w-[200px]">
|
||||||
|
<div className="font-medium text-gray-900 dark:text-white truncate">{h.name}</div>
|
||||||
|
{h.component_name && <div className="text-[10px] text-gray-400 truncate">{h.component_name}</div>}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 border-r border-gray-200 dark:border-gray-600">
|
||||||
|
<span className="inline-block px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 text-[10px] font-medium">
|
||||||
|
{CATEGORY_LABELS[h.category] || h.category}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
{/* Initial S/E/P/RPZ/Risk */}
|
||||||
|
<td className="px-2 py-2 text-center text-gray-700 dark:text-gray-300">{h.severity}</td>
|
||||||
|
<td className="px-2 py-2 text-center text-gray-700 dark:text-gray-300">{h.exposure}</td>
|
||||||
|
<td className="px-2 py-2 text-center text-gray-700 dark:text-gray-300">{h.probability}</td>
|
||||||
|
<td className="px-2 py-2 text-center font-bold text-gray-900 dark:text-white">{initRpz}</td>
|
||||||
|
<td className="px-2 py-2 text-center border-r border-gray-200 dark:border-gray-600">
|
||||||
|
<span className={`inline-block px-1.5 py-0.5 rounded-full text-[10px] font-medium border ${getRiskColor(h.risk_level)}`}>
|
||||||
|
{getRiskLevelLabel(h.risk_level)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
{/* After measures (editable) */}
|
||||||
|
<td className="px-1 py-2 text-center">{e && <InlineSelect value={e.severity} onChange={v => updateEdit(h.id, 'severity', v)} label="S nach" />}</td>
|
||||||
|
<td className="px-1 py-2 text-center">{e && <InlineSelect value={e.exposure} onChange={v => updateEdit(h.id, 'exposure', v)} label="E nach" />}</td>
|
||||||
|
<td className="px-1 py-2 text-center">{e && <InlineSelect value={e.probability} onChange={v => updateEdit(h.id, 'probability', v)} label="P nach" />}</td>
|
||||||
|
<td className="px-2 py-2 text-center font-bold text-purple-900 dark:text-purple-300">{afterRpz}</td>
|
||||||
|
<td className="px-2 py-2 text-center">
|
||||||
|
<span className={`inline-block px-1.5 py-0.5 rounded-full text-[10px] font-medium border ${getRiskColor(afterLevel)}`}>
|
||||||
|
{getRiskLevelLabel(afterLevel)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-1 py-2 text-center border-r border-gray-200 dark:border-gray-600">
|
||||||
|
{changed && (
|
||||||
|
<button onClick={() => handleReassess(h.id)} disabled={saving === h.id}
|
||||||
|
className="px-1.5 py-0.5 bg-purple-600 text-white rounded text-[10px] hover:bg-purple-700 disabled:opacity-50 transition-colors">
|
||||||
|
{saving === h.id ? '...' : 'Speichern'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
{/* SIL / PL */}
|
||||||
|
<td className="px-2 py-2 text-center">
|
||||||
|
<span className={`inline-block px-1.5 py-0.5 rounded text-[10px] font-bold ${SIL_COLORS[sil]}`}>
|
||||||
|
{sil > 0 ? `SIL ${sil}` : '-'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2 text-center border-r border-gray-200 dark:border-gray-600">
|
||||||
|
<span className={`inline-block px-1.5 py-0.5 rounded text-[10px] font-bold ${PL_COLORS[pl]}`}>
|
||||||
|
PL {pl}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
{/* Status */}
|
||||||
|
<td className="px-2 py-2 text-center">
|
||||||
|
<span className={`inline-block px-1.5 py-0.5 rounded text-[10px] font-medium ${mc > 0 ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-500'}`}>
|
||||||
|
{mc}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2 text-center">
|
||||||
|
{afterRpz <= 20 ? (
|
||||||
|
<span className="inline-block w-4 h-4 rounded-full bg-green-500 text-white text-[10px] leading-4 text-center" title="Akzeptabel">✓</span>
|
||||||
|
) : afterRpz <= 60 ? (
|
||||||
|
<span className="inline-block w-4 h-4 rounded-full bg-yellow-400 text-yellow-900 text-[10px] leading-4 text-center" title="Bedingt">≈</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-block w-4 h-4 rounded-full bg-red-500 text-white text-[10px] leading-4 text-center" title="Nicht akzeptabel">✗</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hazards.length === 0 && (
|
||||||
|
<div className="px-4 py-8 text-center text-sm text-gray-500">
|
||||||
|
Keine Gefaehrdungen vorhanden. Fuegen Sie zuerst Gefaehrdungen hinzu.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -169,5 +169,6 @@ export function useHazards(projectId: string) {
|
|||||||
suggestingAI, matchingPatterns, matchResult, setMatchResult, applyingPatterns,
|
suggestingAI, matchingPatterns, matchResult, setMatchResult, applyingPatterns,
|
||||||
fetchLibrary, handleAddFromLibrary, handleSubmit,
|
fetchLibrary, handleAddFromLibrary, handleSubmit,
|
||||||
handleAISuggestions, handlePatternMatching, handleApplyPatterns, handleDelete,
|
handleAISuggestions, handlePatternMatching, handleApplyPatterns, handleDelete,
|
||||||
|
refetch: fetchHazards,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,21 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import React from 'react'
|
import React, { useState } from 'react'
|
||||||
import { useParams } from 'next/navigation'
|
import { useParams } from 'next/navigation'
|
||||||
import { HazardForm } from './_components/HazardForm'
|
import { HazardForm } from './_components/HazardForm'
|
||||||
import { HazardTable } from './_components/HazardTable'
|
import { HazardTable } from './_components/HazardTable'
|
||||||
|
import { RiskAssessmentTable } from './_components/RiskAssessmentTable'
|
||||||
import { LibraryModal } from './_components/LibraryModal'
|
import { LibraryModal } from './_components/LibraryModal'
|
||||||
import { AutoSuggestPanel } from './_components/AutoSuggestPanel'
|
import { AutoSuggestPanel } from './_components/AutoSuggestPanel'
|
||||||
import { useHazards } from './_hooks/useHazards'
|
import { useHazards } from './_hooks/useHazards'
|
||||||
|
|
||||||
|
type ViewMode = 'list' | 'risk'
|
||||||
|
|
||||||
export default function HazardsPage() {
|
export default function HazardsPage() {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const projectId = params.projectId as string
|
const projectId = params.projectId as string
|
||||||
const h = useHazards(projectId)
|
const h = useHazards(projectId)
|
||||||
|
const [view, setView] = useState<ViewMode>('risk')
|
||||||
|
|
||||||
if (h.loading) {
|
if (h.loading) {
|
||||||
return (
|
return (
|
||||||
@@ -29,9 +33,20 @@ export default function HazardsPage() {
|
|||||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
Gefaehrdungsanalyse mit 4-Faktor-Risikobewertung (S x F x P x A).
|
Gefaehrdungsanalyse mit 4-Faktor-Risikobewertung (S x F x P x A).
|
||||||
</p>
|
</p>
|
||||||
|
<div className="mt-2 flex rounded-lg border border-gray-200 dark:border-gray-600 overflow-hidden text-xs">
|
||||||
|
<button onClick={() => setView('list')}
|
||||||
|
className={`px-3 py-1.5 font-medium transition-colors ${view === 'list' ? 'bg-purple-600 text-white' : 'bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-50'}`}>
|
||||||
|
Hazard-Liste
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setView('risk')}
|
||||||
|
className={`px-3 py-1.5 font-medium transition-colors border-l border-gray-200 dark:border-gray-600 ${view === 'risk' ? 'bg-purple-600 text-white' : 'bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-50'}`}>
|
||||||
|
Risikobewertung
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button onClick={h.handlePatternMatching} disabled={h.matchingPatterns}
|
<button onClick={h.handlePatternMatching} disabled={h.matchingPatterns}
|
||||||
|
title="Erkennt automatisch Gefaehrdungen anhand der Komponenten-Tags und Lebensphasen"
|
||||||
className="flex items-center gap-2 px-3 py-2 border border-green-300 text-green-700 rounded-lg hover:bg-green-50 transition-colors disabled:opacity-50 text-sm">
|
className="flex items-center gap-2 px-3 py-2 border border-green-300 text-green-700 rounded-lg hover:bg-green-50 transition-colors disabled:opacity-50 text-sm">
|
||||||
{h.matchingPatterns ? (
|
{h.matchingPatterns ? (
|
||||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-green-600" />
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-green-600" />
|
||||||
@@ -40,18 +55,7 @@ export default function HazardsPage() {
|
|||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
Auto-Erkennung
|
Gefaehrdungen erkennen
|
||||||
</button>
|
|
||||||
<button onClick={h.handleAISuggestions} disabled={h.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">
|
|
||||||
{h.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>
|
||||||
<button onClick={h.fetchLibrary}
|
<button onClick={h.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">
|
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">
|
||||||
@@ -70,12 +74,12 @@ export default function HazardsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{h.matchResult && h.matchResult.matched_patterns.length > 0 && (
|
{h.matchResult && h.matchResult.matched_patterns?.length > 0 && (
|
||||||
<AutoSuggestPanel projectId={projectId} matchResult={h.matchResult} applying={h.applyingPatterns}
|
<AutoSuggestPanel projectId={projectId} matchResult={h.matchResult} applying={h.applyingPatterns}
|
||||||
onApply={h.handleApplyPatterns} onClose={() => h.setMatchResult(null)} />
|
onApply={h.handleApplyPatterns} onClose={() => h.setMatchResult(null)} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{h.matchResult && h.matchResult.matched_patterns.length === 0 && (
|
{h.matchResult && (!h.matchResult.matched_patterns || h.matchResult.matched_patterns.length === 0) && (
|
||||||
<div className="bg-yellow-50 border border-yellow-200 rounded-xl p-4 flex items-start gap-3">
|
<div className="bg-yellow-50 border border-yellow-200 rounded-xl p-4 flex items-start gap-3">
|
||||||
<svg className="w-5 h-5 text-yellow-600 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg className="w-5 h-5 text-yellow-600 mt-0.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" />
|
<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" />
|
||||||
@@ -121,7 +125,11 @@ export default function HazardsPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{h.hazards.length > 0 ? (
|
{h.hazards.length > 0 ? (
|
||||||
<HazardTable hazards={h.hazards} lifecyclePhases={h.lifecyclePhases} onDelete={h.handleDelete} />
|
view === 'risk' ? (
|
||||||
|
<RiskAssessmentTable projectId={projectId} hazards={h.hazards} onReassess={h.refetch} />
|
||||||
|
) : (
|
||||||
|
<HazardTable hazards={h.hazards} lifecyclePhases={h.lifecyclePhases} onDelete={h.handleDelete} />
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
!h.showForm && (
|
!h.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="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-12 text-center">
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
// IACE Interview Types — structured questions based on CE risk assessment document structure
|
||||||
|
|
||||||
|
export interface InterviewQuestion {
|
||||||
|
id: string
|
||||||
|
section: number
|
||||||
|
sectionTitle: string
|
||||||
|
question: string
|
||||||
|
type: 'text' | 'textarea' | 'select' | 'multiselect' | 'number'
|
||||||
|
options?: string[]
|
||||||
|
placeholder?: string
|
||||||
|
helpText?: string
|
||||||
|
required?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InterviewAnswer {
|
||||||
|
questionId: string
|
||||||
|
value: string | string[] | number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const INTERVIEW_QUESTIONS: InterviewQuestion[] = [
|
||||||
|
// Section 1: Maschinenbeschreibung
|
||||||
|
{ id: 'machine_name', section: 1, sectionTitle: 'Maschinenbeschreibung', question: 'Wie heisst die Maschine / Anlage?', type: 'text', placeholder: 'z.B. Kniehebelpresse HP-500', required: true },
|
||||||
|
{ id: 'machine_type', section: 1, sectionTitle: 'Maschinenbeschreibung', question: 'Welcher Maschinentyp ist es?', type: 'select', options: ['Presse', 'Roboter', 'CNC-Maschine', 'Foerderanlage', 'Verpackungsmaschine', 'Schweissanlage', 'Montageanlage', 'Sondermaschine'], required: true },
|
||||||
|
{ id: 'manufacturer', section: 1, sectionTitle: 'Maschinenbeschreibung', question: 'Wer ist der Hersteller?', type: 'text', placeholder: 'z.B. Mueller Maschinenbau GmbH' },
|
||||||
|
{ id: 'description', section: 1, sectionTitle: 'Maschinenbeschreibung', question: 'Beschreiben Sie die Anlage und ihre Funktion:', type: 'textarea', placeholder: 'Die Anlage ist eine vollautomatische...', helpText: 'Beschreiben Sie den Zweck, die Arbeitsweise und den Aufbau der Maschine.', required: true },
|
||||||
|
{ id: 'components', section: 1, sectionTitle: 'Maschinenbeschreibung', question: 'Aus welchen Baugruppen besteht die Anlage?', type: 'multiselect', options: ['Zufuehrung', 'Presse/Umformung', 'Transferanlage', 'Foerderband', 'Roboter', 'Absaugung', 'Schmieranlage', 'Schutzumhausung', 'Aufzug/Hubwerk', 'Schaltschrank/Steuerung', 'Kuehlung', 'Heizung', 'Hydraulik', 'Pneumatik'] },
|
||||||
|
|
||||||
|
// Section 2: Lebensphasen
|
||||||
|
{ id: 'lifecycle_operation', section: 2, sectionTitle: 'Lebensphasen', question: 'Wie laeuft der Normalbetrieb ab?', type: 'textarea', placeholder: 'Die Bearbeitung erfolgt vollautomatisch...', helpText: 'Beschreiben Sie den typischen Produktionszyklus.' },
|
||||||
|
{ id: 'lifecycle_setup', section: 2, sectionTitle: 'Lebensphasen', question: 'Welche Arbeiten fallen beim Einrichten/Umruesten an?', type: 'textarea', placeholder: 'Werkzeugwechsel, Parameteranpassung...' },
|
||||||
|
{ id: 'lifecycle_maintenance', section: 2, sectionTitle: 'Lebensphasen', question: 'Welche Wartungs- und Reinigungsarbeiten sind noetig?', type: 'textarea', placeholder: 'Woechentliche Schmierung, Filter reinigen...' },
|
||||||
|
|
||||||
|
// Section 3: Bestimmungsgemäße Verwendung
|
||||||
|
{ id: 'intended_use', section: 3, sectionTitle: 'Bestimmungsgemäße Verwendung', question: 'Wozu dient die Maschine (bestimmungsgemäße Verwendung)?', type: 'textarea', placeholder: 'Die Anlage dient der automatischen...', required: true },
|
||||||
|
|
||||||
|
// Section 4: Vorhersehbare Fehlanwendung
|
||||||
|
{ id: 'misuse', section: 4, sectionTitle: 'Vorhersehbare Fehlanwendung', question: 'Welche vorhersehbaren Fehlanwendungen sind moeglich?', type: 'multiselect', options: ['Ueberschreiten von Belastungsgrenzen', 'Verwendung ungeeigneter Materialien', 'Betrieb in explosionsgefaehrdeter Atmosphaere', 'Betrieb bei Leckagen', 'Betrieb ohne PSA', 'Umgehung von Sicherheitseinrichtungen', 'Bedienung ohne Einweisung', 'Manipulation der Steuerung'], helpText: 'Waehlen Sie alle zutreffenden oder ergaenzen Sie.' },
|
||||||
|
|
||||||
|
// Section 5: Qualifikation
|
||||||
|
{ id: 'operator_qualification', section: 5, sectionTitle: 'Qualifikation der Benutzer', question: 'Welche Qualifikation hat das Bedienpersonal?', type: 'select', options: ['Eingewiesenes Personal ohne Fachkenntnisse', 'Angelernte Mitarbeiter', 'Facharbeiter mit Berufsausbildung', 'Ingenieure/Techniker', 'Elektrofachkraefte'] },
|
||||||
|
{ id: 'maintenance_qualification', section: 5, sectionTitle: 'Qualifikation der Benutzer', question: 'Wer fuehrt Wartung/Instandhaltung durch?', type: 'select', options: ['Eigenes Fachpersonal', 'Hersteller-Service', 'Fremdfirma', 'Nicht separat betrachtet (CE-Erklaerung Lieferant)'] },
|
||||||
|
|
||||||
|
// Section 6: Grenzen
|
||||||
|
{ id: 'spatial_limits', section: 6, sectionTitle: 'Raeumliche und zeitliche Grenzen', question: 'Welche Gefahrenbereiche gibt es?', type: 'textarea', placeholder: 'Werkzeugeinbauraum, Zufuehrbereich, Auslaufbereich...', helpText: 'Listen Sie alle Bereiche auf, in denen Personen gefaehrdet sein koennten.' },
|
||||||
|
{ id: 'safety_measures_org', section: 6, sectionTitle: 'Raeumliche und zeitliche Grenzen', question: 'Welche organisatorischen Schutzmassnahmen gelten?', type: 'multiselect', options: ['Sicherheitsschuhe Pflicht', 'Gehoerschutz Pflicht', 'Handschuhe Pflicht', 'Schutzbrille Pflicht', 'Zutrittsbeschraenkung', 'Unterweisung vor Zugang'] },
|
||||||
|
|
||||||
|
// Section 7: Technische Daten
|
||||||
|
{ id: 'force_pressure', section: 7, sectionTitle: 'Technische Daten', question: 'Welche Kraefte/Druecke wirken? (kN, bar, Tonnen)', type: 'text', placeholder: 'z.B. 20000 kN, 250 bar' },
|
||||||
|
{ id: 'voltage', section: 7, sectionTitle: 'Technische Daten', question: 'Welche Spannungen sind vorhanden? (V)', type: 'text', placeholder: 'z.B. 400V Hauptstrom, 24V Steuerung' },
|
||||||
|
{ id: 'temperature', section: 7, sectionTitle: 'Technische Daten', question: 'Treten erhoehte Temperaturen auf? (°C)', type: 'text', placeholder: 'z.B. 130°C Werkstuecktemperatur' },
|
||||||
|
{ id: 'speed_rpm', section: 7, sectionTitle: 'Technische Daten', question: 'Welche Geschwindigkeiten/Drehzahlen gibt es? (/min, m/s)', type: 'text', placeholder: 'z.B. 736 /min Schwungrad, 36 Huebe/min' },
|
||||||
|
{ id: 'energy', section: 7, sectionTitle: 'Technische Daten', question: 'Welches Arbeitsvermoegen hat die Maschine? (kJ, kW)', type: 'text', placeholder: 'z.B. 400 kJ, 3 kW Motor' },
|
||||||
|
|
||||||
|
// Section 8: Umgebung
|
||||||
|
{ id: 'environment', section: 8, sectionTitle: 'Umgebungsbedingungen', question: 'Unter welchen Umgebungsbedingungen wird die Maschine betrieben?', type: 'textarea', placeholder: '+5 bis +40°C, max. 95% Luftfeuchte, bis 1000m ueNN', helpText: 'Temperatur, Luftfeuchte, Hoehenlage, besondere Bedingungen.' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function answersToNarrativeText(answers: InterviewAnswer[]): string {
|
||||||
|
const parts: string[] = []
|
||||||
|
const getVal = (id: string) => {
|
||||||
|
const a = answers.find(a => a.questionId === id)
|
||||||
|
if (!a) return ''
|
||||||
|
return Array.isArray(a.value) ? (a.value as string[]).join(', ') : String(a.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.push(`Maschinenname: ${getVal('machine_name')}. Maschinentyp: ${getVal('machine_type')}. Hersteller: ${getVal('manufacturer')}.`)
|
||||||
|
if (getVal('description')) parts.push(getVal('description'))
|
||||||
|
if (getVal('components')) parts.push(`Baugruppen: ${getVal('components')}.`)
|
||||||
|
if (getVal('lifecycle_operation')) parts.push(`Betrieb: ${getVal('lifecycle_operation')}`)
|
||||||
|
if (getVal('lifecycle_setup')) parts.push(`Einrichten: ${getVal('lifecycle_setup')}`)
|
||||||
|
if (getVal('lifecycle_maintenance')) parts.push(`Wartung: ${getVal('lifecycle_maintenance')}`)
|
||||||
|
if (getVal('intended_use')) parts.push(`Bestimmungsgemäße Verwendung: ${getVal('intended_use')}`)
|
||||||
|
if (getVal('misuse')) parts.push(`Vorhersehbare Fehlanwendung: ${getVal('misuse')}`)
|
||||||
|
if (getVal('operator_qualification')) parts.push(`Bedienpersonal: ${getVal('operator_qualification')}`)
|
||||||
|
if (getVal('spatial_limits')) parts.push(`Gefahrenbereiche: ${getVal('spatial_limits')}`)
|
||||||
|
if (getVal('safety_measures_org')) parts.push(`Organisatorische Massnahmen: ${getVal('safety_measures_org')}`)
|
||||||
|
if (getVal('force_pressure')) parts.push(getVal('force_pressure'))
|
||||||
|
if (getVal('voltage')) parts.push(getVal('voltage'))
|
||||||
|
if (getVal('temperature')) parts.push(getVal('temperature'))
|
||||||
|
if (getVal('speed_rpm')) parts.push(getVal('speed_rpm'))
|
||||||
|
if (getVal('energy')) parts.push(getVal('energy'))
|
||||||
|
if (getVal('environment')) parts.push(`Umgebung: ${getVal('environment')}`)
|
||||||
|
|
||||||
|
return parts.join('\n')
|
||||||
|
}
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useParams } from 'next/navigation'
|
||||||
|
import { INTERVIEW_QUESTIONS, answersToNarrativeText, type InterviewAnswer, type InterviewQuestion } from './_types'
|
||||||
|
|
||||||
|
type InputMode = 'interview' | 'wizard' | 'form'
|
||||||
|
|
||||||
|
export default function IACEInterviewPage() {
|
||||||
|
const { projectId } = useParams<{ projectId: string }>()
|
||||||
|
const [mode, setMode] = useState<InputMode>('interview')
|
||||||
|
const [answers, setAnswers] = useState<InterviewAnswer[]>([])
|
||||||
|
const [currentQ, setCurrentQ] = useState(0)
|
||||||
|
const [currentSection, setCurrentSection] = useState(1)
|
||||||
|
const [analyzing, setAnalyzing] = useState(false)
|
||||||
|
const [result, setResult] = useState<any>(null)
|
||||||
|
const [inputValue, setInputValue] = useState('')
|
||||||
|
const [multiValue, setMultiValue] = useState<string[]>([])
|
||||||
|
|
||||||
|
const setAnswer = (qId: string, value: string | string[] | number) => {
|
||||||
|
setAnswers(prev => {
|
||||||
|
const existing = prev.findIndex(a => a.questionId === qId)
|
||||||
|
if (existing >= 0) { prev[existing].value = value; return [...prev] }
|
||||||
|
return [...prev, { questionId: qId, value }]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const getAnswer = (qId: string) => answers.find(a => a.questionId === qId)?.value || ''
|
||||||
|
|
||||||
|
const handleAnalyze = async () => {
|
||||||
|
setAnalyzing(true)
|
||||||
|
const narrativeText = answersToNarrativeText(answers)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/parse-narrative`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ narrative_text: narrativeText }),
|
||||||
|
})
|
||||||
|
if (res.ok) setResult(await res.json())
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
setAnalyzing(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const q = INTERVIEW_QUESTIONS[currentQ]
|
||||||
|
const sections = [...new Set(INTERVIEW_QUESTIONS.map(q => q.section))]
|
||||||
|
const sectionQuestions = (s: number) => INTERVIEW_QUESTIONS.filter(q => q.section === s)
|
||||||
|
|
||||||
|
// Interview mode: advance to next question
|
||||||
|
const handleInterviewNext = () => {
|
||||||
|
if (q.type === 'multiselect') { setAnswer(q.id, multiValue); setMultiValue([]) }
|
||||||
|
else if (inputValue) { setAnswer(q.id, inputValue); setInputValue('') }
|
||||||
|
if (currentQ < INTERVIEW_QUESTIONS.length - 1) setCurrentQ(currentQ + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Mode Switcher */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-xl font-bold text-gray-900">CE-Risikobeurteilung — Datenerfassung</h1>
|
||||||
|
<div className="flex gap-1 bg-gray-100 rounded-lg p-1">
|
||||||
|
{([['interview', 'Interview'], ['wizard', 'Wizard'], ['form', 'Formular']] as [InputMode, string][]).map(([m, label]) => (
|
||||||
|
<button key={m} onClick={() => setMode(m)}
|
||||||
|
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${mode === m ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'}`}>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Result */}
|
||||||
|
{result && (
|
||||||
|
<div className="bg-green-50 border border-green-200 rounded-xl p-6 space-y-4">
|
||||||
|
<h2 className="font-semibold text-green-900">Analyse-Ergebnis (deterministisch)</h2>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
|
<div className="bg-white rounded-lg p-3 text-center"><div className="text-2xl font-bold text-purple-600">{result.components?.length || 0}</div><div className="text-xs text-gray-500">Komponenten</div></div>
|
||||||
|
<div className="bg-white rounded-lg p-3 text-center"><div className="text-2xl font-bold text-red-600">{result.suggested_hazards?.length || 0}</div><div className="text-xs text-gray-500">Gefahren</div></div>
|
||||||
|
<div className="bg-white rounded-lg p-3 text-center"><div className="text-2xl font-bold text-blue-600">{result.matched_patterns || 0}</div><div className="text-xs text-gray-500">Patterns</div></div>
|
||||||
|
<div className="bg-white rounded-lg p-3 text-center"><div className="text-2xl font-bold text-green-600">{result.energy_sources?.length || 0}</div><div className="text-xs text-gray-500">Energiequellen</div></div>
|
||||||
|
</div>
|
||||||
|
{result.suggested_hazards?.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="font-medium text-gray-900 text-sm">Erkannte Gefahren:</h3>
|
||||||
|
{result.suggested_hazards.map((h: any, i: number) => (
|
||||||
|
<div key={i} className="flex items-center gap-3 bg-white rounded-lg p-2 border border-gray-100">
|
||||||
|
<span className={`px-2 py-0.5 text-[10px] font-medium rounded-full ${h.priority >= 90 ? 'bg-red-100 text-red-700' : h.priority >= 70 ? 'bg-orange-100 text-orange-700' : 'bg-yellow-100 text-yellow-700'}`}>P{h.priority}</span>
|
||||||
|
<span className="text-sm text-gray-700">{h.pattern_name || h.category}</span>
|
||||||
|
<span className="text-xs text-gray-400 ml-auto">{h.category}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ═══════════════ INTERVIEW MODE ═══════════════ */}
|
||||||
|
{mode === 'interview' && !result && (
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-6 max-w-2xl mx-auto">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Previous answers (chat history) */}
|
||||||
|
<div className="space-y-3 max-h-[400px] overflow-y-auto">
|
||||||
|
{INTERVIEW_QUESTIONS.slice(0, currentQ).map((pq, i) => {
|
||||||
|
const ans = getAnswer(pq.id)
|
||||||
|
if (!ans || (Array.isArray(ans) && ans.length === 0)) return null
|
||||||
|
return (
|
||||||
|
<div key={i} className="space-y-1">
|
||||||
|
<div className="text-xs text-purple-600 font-medium">{pq.question}</div>
|
||||||
|
<div className="text-sm text-gray-700 bg-gray-50 rounded-lg px-3 py-2">
|
||||||
|
{Array.isArray(ans) ? ans.join(', ') : String(ans)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Current question */}
|
||||||
|
{currentQ < INTERVIEW_QUESTIONS.length && (
|
||||||
|
<div className="border-t border-gray-100 pt-4">
|
||||||
|
<div className="text-xs text-gray-400 mb-1">Frage {currentQ + 1}/{INTERVIEW_QUESTIONS.length} — {q.sectionTitle}</div>
|
||||||
|
<div className="text-sm font-medium text-gray-900 mb-3">{q.question}</div>
|
||||||
|
{q.helpText && <p className="text-xs text-gray-500 mb-2">{q.helpText}</p>}
|
||||||
|
|
||||||
|
{q.type === 'text' && (
|
||||||
|
<input value={inputValue} onChange={e => setInputValue(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleInterviewNext()}
|
||||||
|
placeholder={q.placeholder} className="w-full px-4 py-2 border border-gray-200 rounded-lg text-sm focus:ring-2 focus:ring-purple-500" autoFocus />
|
||||||
|
)}
|
||||||
|
{q.type === 'textarea' && (
|
||||||
|
<textarea value={inputValue} onChange={e => setInputValue(e.target.value)} rows={4}
|
||||||
|
placeholder={q.placeholder} className="w-full px-4 py-2 border border-gray-200 rounded-lg text-sm focus:ring-2 focus:ring-purple-500" autoFocus />
|
||||||
|
)}
|
||||||
|
{q.type === 'select' && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{q.options?.map(opt => (
|
||||||
|
<button key={opt} onClick={() => { setAnswer(q.id, opt); if (currentQ < INTERVIEW_QUESTIONS.length - 1) setCurrentQ(currentQ + 1) }}
|
||||||
|
className="px-3 py-1.5 text-sm bg-gray-50 border border-gray-200 rounded-lg hover:bg-purple-50 hover:border-purple-300">
|
||||||
|
{opt}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{q.type === 'multiselect' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{q.options?.map(opt => (
|
||||||
|
<label key={opt} className={`flex items-center gap-2 px-3 py-1.5 text-sm rounded-lg border cursor-pointer ${multiValue.includes(opt) ? 'bg-purple-50 border-purple-300' : 'bg-gray-50 border-gray-200'}`}>
|
||||||
|
<input type="checkbox" checked={multiValue.includes(opt)} onChange={e => setMultiValue(e.target.checked ? [...multiValue, opt] : multiValue.filter(v => v !== opt))} className="w-3.5 h-3.5 text-purple-600" />
|
||||||
|
{opt}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-between mt-4">
|
||||||
|
<button onClick={() => currentQ > 0 && setCurrentQ(currentQ - 1)} disabled={currentQ === 0}
|
||||||
|
className="px-4 py-2 text-sm text-gray-500 hover:text-gray-700 disabled:opacity-30">Zurueck</button>
|
||||||
|
<button onClick={handleInterviewNext}
|
||||||
|
className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700">
|
||||||
|
{currentQ === INTERVIEW_QUESTIONS.length - 1 ? 'Abschliessen' : 'Weiter'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentQ >= INTERVIEW_QUESTIONS.length && (
|
||||||
|
<button onClick={handleAnalyze} disabled={analyzing}
|
||||||
|
className="w-full px-6 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 font-medium disabled:opacity-50">
|
||||||
|
{analyzing ? 'Analysiere deterministisch...' : 'Risikobeurteilung starten'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ═══════════════ WIZARD MODE ═══════════════ */}
|
||||||
|
{mode === 'wizard' && !result && (
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||||
|
{/* Step indicator */}
|
||||||
|
<div className="flex items-center gap-2 mb-6">
|
||||||
|
{sections.map(s => (
|
||||||
|
<button key={s} onClick={() => setCurrentSection(s)}
|
||||||
|
className={`w-8 h-8 rounded-full text-xs font-medium ${currentSection === s ? 'bg-purple-600 text-white' : s < currentSection ? 'bg-green-500 text-white' : 'bg-gray-200 text-gray-500'}`}>
|
||||||
|
{s}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<h2 className="font-semibold text-gray-900 mb-4">{INTERVIEW_QUESTIONS.find(q => q.section === currentSection)?.sectionTitle}</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{sectionQuestions(currentSection).map(q => (
|
||||||
|
<div key={q.id}>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">{q.question}</label>
|
||||||
|
{q.type === 'textarea' ? (
|
||||||
|
<textarea value={String(getAnswer(q.id))} onChange={e => setAnswer(q.id, e.target.value)} rows={3} placeholder={q.placeholder}
|
||||||
|
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm" />
|
||||||
|
) : q.type === 'select' ? (
|
||||||
|
<select value={String(getAnswer(q.id))} onChange={e => setAnswer(q.id, e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm bg-white">
|
||||||
|
<option value="">-- Bitte waehlen --</option>
|
||||||
|
{q.options?.map(o => <option key={o} value={o}>{o}</option>)}
|
||||||
|
</select>
|
||||||
|
) : q.type === 'multiselect' ? (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{q.options?.map(opt => {
|
||||||
|
const current = (getAnswer(q.id) as string[] || [])
|
||||||
|
return (
|
||||||
|
<label key={opt} className={`flex items-center gap-1.5 px-2 py-1 text-xs rounded border cursor-pointer ${current.includes(opt) ? 'bg-purple-50 border-purple-300' : 'border-gray-200'}`}>
|
||||||
|
<input type="checkbox" checked={current.includes(opt)} onChange={e => setAnswer(q.id, e.target.checked ? [...current, opt] : current.filter((v: string) => v !== opt))} className="w-3 h-3" />
|
||||||
|
{opt}
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<input value={String(getAnswer(q.id))} onChange={e => setAnswer(q.id, e.target.value)} placeholder={q.placeholder}
|
||||||
|
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between mt-6 pt-4 border-t">
|
||||||
|
<button onClick={() => setCurrentSection(Math.max(1, currentSection - 1))} disabled={currentSection === 1}
|
||||||
|
className="px-4 py-2 text-sm text-gray-500 disabled:opacity-30">Zurueck</button>
|
||||||
|
{currentSection < sections.length ? (
|
||||||
|
<button onClick={() => setCurrentSection(currentSection + 1)} className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg">Weiter</button>
|
||||||
|
) : (
|
||||||
|
<button onClick={handleAnalyze} disabled={analyzing} className="px-6 py-2 bg-green-600 text-white rounded-lg font-medium disabled:opacity-50">
|
||||||
|
{analyzing ? 'Analysiere...' : 'Risikobeurteilung starten'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ═══════════════ FORM MODE (Accordion) ═══════════════ */}
|
||||||
|
{mode === 'form' && !result && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{sections.map(s => {
|
||||||
|
const qs = sectionQuestions(s)
|
||||||
|
const title = qs[0]?.sectionTitle || ''
|
||||||
|
return (
|
||||||
|
<details key={s} open={s === 1} className="bg-white rounded-xl border border-gray-200">
|
||||||
|
<summary className="px-6 py-4 cursor-pointer font-medium text-gray-900 hover:bg-gray-50">{s}. {title}</summary>
|
||||||
|
<div className="px-6 pb-4 space-y-3">
|
||||||
|
{qs.map(q => (
|
||||||
|
<div key={q.id}>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">{q.question}</label>
|
||||||
|
{q.type === 'textarea' ? (
|
||||||
|
<textarea value={String(getAnswer(q.id))} onChange={e => setAnswer(q.id, e.target.value)} rows={3} placeholder={q.placeholder}
|
||||||
|
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm" />
|
||||||
|
) : q.type === 'select' ? (
|
||||||
|
<select value={String(getAnswer(q.id))} onChange={e => setAnswer(q.id, e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm bg-white">
|
||||||
|
<option value="">-- Bitte waehlen --</option>
|
||||||
|
{q.options?.map(o => <option key={o} value={o}>{o}</option>)}
|
||||||
|
</select>
|
||||||
|
) : q.type === 'multiselect' ? (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{q.options?.map(opt => {
|
||||||
|
const current = (getAnswer(q.id) as string[] || [])
|
||||||
|
return (
|
||||||
|
<label key={opt} className={`flex items-center gap-1.5 px-2 py-1 text-xs rounded border cursor-pointer ${current.includes(opt) ? 'bg-purple-50 border-purple-300' : 'border-gray-200'}`}>
|
||||||
|
<input type="checkbox" checked={current.includes(opt)} onChange={e => setAnswer(q.id, e.target.checked ? [...current, opt] : current.filter((v: string) => v !== opt))} className="w-3 h-3" />
|
||||||
|
{opt}
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<input value={String(getAnswer(q.id))} onChange={e => setAnswer(q.id, e.target.value)} placeholder={q.placeholder}
|
||||||
|
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<button onClick={handleAnalyze} disabled={analyzing}
|
||||||
|
className="w-full px-6 py-3 bg-green-600 text-white rounded-xl hover:bg-green-700 font-medium disabled:opacity-50 text-lg">
|
||||||
|
{analyzing ? 'Analysiere deterministisch...' : 'Risikobeurteilung starten'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+3
-3
@@ -16,8 +16,8 @@ export function MitigationCard({
|
|||||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
<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">
|
<div className="flex items-start justify-between mb-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<h4 className="text-sm font-medium text-gray-900 dark:text-white">{mitigation.title}</h4>
|
<h4 className="text-sm font-medium text-gray-900 dark:text-white">{mitigation.title || ''}</h4>
|
||||||
{mitigation.title.startsWith('Auto:') && (
|
{(mitigation.title || '').startsWith('Auto:') && (
|
||||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-green-100 text-green-700">
|
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-green-100 text-green-700">
|
||||||
Auto
|
Auto
|
||||||
</span>
|
</span>
|
||||||
@@ -28,7 +28,7 @@ export function MitigationCard({
|
|||||||
{mitigation.description && (
|
{mitigation.description && (
|
||||||
<p className="text-xs text-gray-500 mb-3">{mitigation.description}</p>
|
<p className="text-xs text-gray-500 mb-3">{mitigation.description}</p>
|
||||||
)}
|
)}
|
||||||
{mitigation.linked_hazard_names.length > 0 && (
|
{(mitigation.linked_hazard_names || []).length > 0 && (
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{mitigation.linked_hazard_names.map((name, i) => (
|
{mitigation.linked_hazard_names.map((name, i) => (
|
||||||
|
|||||||
@@ -20,15 +20,33 @@ export function useMitigations(projectId: string) {
|
|||||||
fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations`),
|
fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations`),
|
||||||
fetch(`/api/sdk/v1/iace/projects/${projectId}/hazards`),
|
fetch(`/api/sdk/v1/iace/projects/${projectId}/hazards`),
|
||||||
])
|
])
|
||||||
if (mitRes.ok) {
|
let hazardList: Hazard[] = []
|
||||||
const json = await mitRes.json()
|
|
||||||
const mits = json.mitigations || json || []
|
|
||||||
setMitigations(mits)
|
|
||||||
validateHierarchy(mits)
|
|
||||||
}
|
|
||||||
if (hazRes.ok) {
|
if (hazRes.ok) {
|
||||||
const json = await hazRes.json()
|
const json = await hazRes.json()
|
||||||
setHazards((json.hazards || json || []).map((h: Hazard) => ({ id: h.id, name: h.name, risk_level: h.risk_level, category: h.category })))
|
hazardList = (json.hazards || json || []).map((h: Hazard) => ({ id: h.id, name: h.name, risk_level: h.risk_level, category: h.category }))
|
||||||
|
setHazards(hazardList)
|
||||||
|
}
|
||||||
|
if (mitRes.ok) {
|
||||||
|
const json = await mitRes.json()
|
||||||
|
const raw = json.mitigations || json || []
|
||||||
|
// Map API fields (name, hazard_id) to frontend fields (title, linked_hazard_ids/names)
|
||||||
|
const hazardMap = Object.fromEntries(hazardList.map((h) => [h.id, h.name]))
|
||||||
|
const mits: Mitigation[] = raw.map((m: Record<string, unknown>) => ({
|
||||||
|
id: m.id as string,
|
||||||
|
title: (m.title || m.name || '') as string,
|
||||||
|
description: (m.description || '') as string,
|
||||||
|
reduction_type: (m.reduction_type === 'protective' ? 'protection' : m.reduction_type || 'design') as Mitigation['reduction_type'],
|
||||||
|
status: (m.status || 'planned') as Mitigation['status'],
|
||||||
|
linked_hazard_ids: m.linked_hazard_ids ? (m.linked_hazard_ids as string[]) : m.hazard_id ? [m.hazard_id as string] : [],
|
||||||
|
linked_hazard_names: m.linked_hazard_ids
|
||||||
|
? (m.linked_hazard_ids as string[]).map((id) => hazardMap[id] || id)
|
||||||
|
: m.hazard_id ? [hazardMap[m.hazard_id as string] || (m.hazard_id as string)] : [],
|
||||||
|
created_at: (m.created_at || '') as string,
|
||||||
|
verified_at: (m.verified_at || null) as string | null,
|
||||||
|
verified_by: (m.verified_by || null) as string | null,
|
||||||
|
}))
|
||||||
|
setMitigations(mits)
|
||||||
|
validateHierarchy(mits)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch data:', err)
|
console.error('Failed to fetch data:', err)
|
||||||
@@ -128,7 +146,7 @@ export function useMitigations(projectId: string) {
|
|||||||
|
|
||||||
const byType = {
|
const byType = {
|
||||||
design: mitigations.filter((m) => m.reduction_type === 'design'),
|
design: mitigations.filter((m) => m.reduction_type === 'design'),
|
||||||
protection: mitigations.filter((m) => m.reduction_type === 'protection'),
|
protection: mitigations.filter((m) => m.reduction_type === 'protection' || m.reduction_type === 'protective'),
|
||||||
information: mitigations.filter((m) => m.reduction_type === 'information'),
|
information: mitigations.filter((m) => m.reduction_type === 'information'),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,12 @@
|
|||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useParams } from 'next/navigation'
|
import { useParams } from 'next/navigation'
|
||||||
import { REDUCTION_TYPES } from './_components/types'
|
import { REDUCTION_TYPES, Mitigation } from './_components/types'
|
||||||
import { HierarchyWarning } from './_components/HierarchyWarning'
|
import { HierarchyWarning } from './_components/HierarchyWarning'
|
||||||
import { MeasuresLibraryModal } from './_components/MeasuresLibraryModal'
|
import { MeasuresLibraryModal } from './_components/MeasuresLibraryModal'
|
||||||
import { SuggestMeasuresModal } from './_components/SuggestMeasuresModal'
|
import { SuggestMeasuresModal } from './_components/SuggestMeasuresModal'
|
||||||
import { MitigationForm } from './_components/MitigationForm'
|
import { MitigationForm } from './_components/MitigationForm'
|
||||||
import { MitigationCard } from './_components/MitigationCard'
|
import { StatusBadge } from './_components/StatusBadge'
|
||||||
import { ProtectiveMeasure } from './_components/types'
|
import { ProtectiveMeasure } from './_components/types'
|
||||||
import { useMitigations } from './_hooks/useMitigations'
|
import { useMitigations } from './_hooks/useMitigations'
|
||||||
|
|
||||||
@@ -26,6 +26,47 @@ export default function MitigationsPage() {
|
|||||||
const [showLibrary, setShowLibrary] = useState(false)
|
const [showLibrary, setShowLibrary] = useState(false)
|
||||||
const [libraryFilter, setLibraryFilter] = useState<string | undefined>()
|
const [libraryFilter, setLibraryFilter] = useState<string | undefined>()
|
||||||
const [showSuggest, setShowSuggest] = useState(false)
|
const [showSuggest, setShowSuggest] = useState(false)
|
||||||
|
const [expanded, setExpanded] = useState<Record<string, boolean>>({ design: true, protection: true, information: true })
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||||
|
const [batchAction, setBatchAction] = useState<'verify' | 'delete' | null>(null)
|
||||||
|
|
||||||
|
function toggleSection(type: string) {
|
||||||
|
setExpanded((prev) => ({ ...prev, [type]: !prev[type] }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSelect(id: string) {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(id)) next.delete(id); else next.add(id)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectAllInType(type: string) {
|
||||||
|
const items = byType[type as keyof typeof byType]
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
const allSelected = items.every((m) => next.has(m.id))
|
||||||
|
if (allSelected) { items.forEach((m) => next.delete(m.id)) }
|
||||||
|
else { items.forEach((m) => next.add(m.id)) }
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleBatchVerify() {
|
||||||
|
setBatchAction('verify')
|
||||||
|
for (const id of selected) { await handleVerify(id) }
|
||||||
|
setSelected(new Set())
|
||||||
|
setBatchAction(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleBatchDelete() {
|
||||||
|
if (!confirm(`${selected.size} Massnahmen wirklich loeschen?`)) return
|
||||||
|
setBatchAction('delete')
|
||||||
|
for (const id of selected) { await handleDelete(id) }
|
||||||
|
setSelected(new Set())
|
||||||
|
setBatchAction(null)
|
||||||
|
}
|
||||||
|
|
||||||
function handleOpenLibrary(type?: string) {
|
function handleOpenLibrary(type?: string) {
|
||||||
setLibraryFilter(type)
|
setLibraryFilter(type)
|
||||||
@@ -39,11 +80,6 @@ export default function MitigationsPage() {
|
|||||||
setPreselectedType(measure.reduction_type as 'design' | 'protection' | 'information')
|
setPreselectedType(measure.reduction_type as 'design' | 'protection' | 'information')
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleAddForType(type: 'design' | 'protection' | 'information') {
|
|
||||||
setPreselectedType(type)
|
|
||||||
setShowForm(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-64">
|
<div className="flex items-center justify-center h-64">
|
||||||
@@ -52,42 +88,51 @@ export default function MitigationsPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const totalMeasures = byType.design.length + byType.protection.length + byType.information.length
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-4">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Massnahmen</h1>
|
<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">
|
<p className="mt-1 text-sm text-gray-500">
|
||||||
Risikominderung nach dem 3-Stufen-Verfahren: Design → Schutz → Information.
|
{totalMeasures} Massnahmen nach 3-Stufen-Verfahren: Design ({byType.design.length}) → Schutz ({byType.protection.length}) → Information ({byType.information.length})
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-2">
|
||||||
{m.hazards.length > 0 && (
|
{selected.size > 0 && (
|
||||||
<button onClick={() => m.setShowSuggest(true)}
|
<>
|
||||||
className="flex items-center gap-2 px-3 py-2 border border-green-300 text-green-700 rounded-lg hover:bg-green-50 transition-colors text-sm">
|
<span className="text-xs text-gray-500">{selected.size} ausgewaehlt</span>
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<button onClick={handleBatchVerify} disabled={batchAction !== null}
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
|
className="px-3 py-1.5 text-xs bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50">
|
||||||
</svg>
|
{batchAction === 'verify' ? 'Verifiziere...' : 'Verifizieren'}
|
||||||
Vorschlaege
|
</button>
|
||||||
</button>
|
<button onClick={handleBatchDelete} disabled={batchAction !== null}
|
||||||
|
className="px-3 py-1.5 text-xs bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50">
|
||||||
|
Loeschen
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setSelected(new Set())} className="px-2 py-1.5 text-xs text-gray-500 hover:text-gray-700">
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{selected.size === 0 && (
|
||||||
|
<>
|
||||||
|
<button onClick={() => setShowSuggest(true)}
|
||||||
|
className="px-3 py-1.5 text-xs border border-green-300 text-green-700 rounded-lg hover:bg-green-50">
|
||||||
|
Vorschlaege
|
||||||
|
</button>
|
||||||
|
<button onClick={() => handleOpenLibrary()}
|
||||||
|
className="px-3 py-1.5 text-xs border border-purple-300 text-purple-700 rounded-lg hover:bg-purple-50">
|
||||||
|
Bibliothek
|
||||||
|
</button>
|
||||||
|
<button onClick={() => { setPreselectedType(undefined); setShowForm(true) }}
|
||||||
|
className="px-3 py-1.5 text-xs bg-purple-600 text-white rounded-lg hover:bg-purple-700">
|
||||||
|
+ Hinzufuegen
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<button onClick={() => m.handleOpenLibrary()}
|
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-white border border-purple-300 text-purple-700 rounded-lg hover:bg-purple-50 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 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>
|
|
||||||
Bibliothek
|
|
||||||
</button>
|
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -95,76 +140,80 @@ export default function MitigationsPage() {
|
|||||||
|
|
||||||
{showForm && (
|
{showForm && (
|
||||||
<MitigationForm
|
<MitigationForm
|
||||||
onSubmit={async (data) => {
|
onSubmit={async (data) => { const ok = await handleSubmit(data); if (ok) setShowForm(false) }}
|
||||||
const ok = await handleSubmit(data)
|
onCancel={() => setShowForm(false)} hazards={hazards} preselectedType={preselectedType} onOpenLibrary={handleOpenLibrary}
|
||||||
if (ok) { setShowForm(false); setPreselectedType(undefined) }
|
|
||||||
}}
|
|
||||||
onCancel={() => { setShowForm(false); setPreselectedType(undefined) }}
|
|
||||||
hazards={hazards}
|
|
||||||
preselectedType={preselectedType}
|
|
||||||
onOpenLibrary={handleOpenLibrary}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{showLibrary && <MeasuresLibraryModal measures={measures} onSelect={handleSelectMeasure} onClose={() => setShowLibrary(false)} filterType={libraryFilter} />}
|
||||||
|
{showSuggest && <SuggestMeasuresModal hazards={hazards} projectId={projectId} onAddMeasure={handleAddSuggestedMeasure} onClose={() => setShowSuggest(false)} />}
|
||||||
|
|
||||||
{showLibrary && (
|
{/* 3-Step Accordions */}
|
||||||
<MeasuresLibraryModal
|
{(['design', 'protection', 'information'] as const).map((type) => {
|
||||||
measures={m.measures} onSelect={m.handleSelectMeasure}
|
const config = REDUCTION_TYPES[type]
|
||||||
onClose={() => m.setShowLibrary(false)} filterType={m.libraryFilter}
|
const items = byType[type]
|
||||||
/>
|
const isExpanded = expanded[type]
|
||||||
)}
|
const allSelected = items.length > 0 && items.every((m) => selected.has(m.id))
|
||||||
|
|
||||||
{showSuggest && (
|
return (
|
||||||
<SuggestMeasuresModal
|
<div key={type} className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
hazards={m.hazards} projectId={projectId}
|
{/* Accordion Header */}
|
||||||
onAddMeasure={m.handleAddSuggestedMeasure}
|
<button onClick={() => toggleSection(type)}
|
||||||
onClose={() => m.setShowSuggest(false)}
|
className={`w-full flex items-center gap-3 px-4 py-3 text-left transition-colors ${config.headerColor}`}>
|
||||||
/>
|
<svg className={`w-4 h-4 transition-transform ${isExpanded ? '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>
|
||||||
|
{config.icon}
|
||||||
|
<div className="flex-1">
|
||||||
|
<span className="text-sm font-semibold">{config.label}</span>
|
||||||
|
<span className="ml-2 text-xs opacity-75">{config.description}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-bold">{items.length}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
{/* 3-Column Layout */}
|
{/* Accordion Content — Table rows */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
{isExpanded && items.length > 0 && (
|
||||||
{(['design', 'protection', 'information'] as const).map((type) => {
|
<div className="border-t border-gray-100 dark:border-gray-700">
|
||||||
const config = REDUCTION_TYPES[type]
|
{/* Table header */}
|
||||||
const items = m.byType[type]
|
<div className="flex items-center gap-2 px-4 py-2 bg-gray-50 dark:bg-gray-750 text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
return (
|
<div className="w-6">
|
||||||
<div key={type} className={`rounded-xl border ${config.color} p-4`}>
|
<input type="checkbox" checked={allSelected} onChange={() => selectAllInType(type)}
|
||||||
<div className={`flex items-center gap-2 px-3 py-2 rounded-lg ${config.headerColor} mb-3`}>
|
className="accent-purple-600" title="Alle auswaehlen" />
|
||||||
{config.icon}
|
</div>
|
||||||
<div>
|
<div className="flex-1">Massnahme</div>
|
||||||
<h3 className="text-sm font-semibold">{config.label}</h3>
|
<div className="w-24">Status</div>
|
||||||
<p className="text-xs opacity-75">{config.description}</p>
|
<div className="w-32">Gefaehrdung</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="ml-auto text-sm font-bold">{items.length}</span>
|
{/* Rows */}
|
||||||
</div>
|
|
||||||
<div className="mb-3 flex flex-wrap gap-1">
|
|
||||||
{config.subTypes.map((st) => (
|
|
||||||
<span key={st.value} className="text-xs px-1.5 py-0.5 rounded bg-white/60 text-gray-500 border border-gray-200/50">
|
|
||||||
{st.label}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{items.map((m) => (
|
{items.map((m) => (
|
||||||
<MitigationCard key={m.id} mitigation={m} onVerify={handleVerify} onDelete={handleDelete} />
|
<div key={m.id}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2 border-t border-gray-50 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors ${selected.has(m.id) ? 'bg-purple-50 dark:bg-purple-900/10' : ''}`}>
|
||||||
|
<div className="w-6">
|
||||||
|
<input type="checkbox" checked={selected.has(m.id)} onChange={() => toggleSelect(m.id)}
|
||||||
|
className="accent-purple-600" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm text-gray-900 dark:text-white truncate">{m.title || ''}</div>
|
||||||
|
{m.description && <div className="text-xs text-gray-400 truncate">{m.description}</div>}
|
||||||
|
</div>
|
||||||
|
<div className="w-24">
|
||||||
|
<StatusBadge status={m.status} />
|
||||||
|
</div>
|
||||||
|
<div className="w-32 text-xs text-gray-500 truncate">
|
||||||
|
{(m.linked_hazard_names || []).join(', ') || '-'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 flex gap-2">
|
)}
|
||||||
<button onClick={() => m.handleAddForType(type)}
|
|
||||||
className="flex-1 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">
|
{isExpanded && items.length === 0 && (
|
||||||
+ Hinzufuegen
|
<div className="px-4 py-6 text-center text-sm text-gray-400 border-t border-gray-100">
|
||||||
</button>
|
Keine Massnahmen in dieser Stufe
|
||||||
<button onClick={() => m.handleOpenLibrary(type)}
|
|
||||||
className="py-2 px-3 text-sm text-gray-400 hover:text-purple-600 hover:bg-white rounded-lg border border-dashed border-gray-300 hover:border-purple-300 transition-colors"
|
|
||||||
title="Aus Bibliothek waehlen">
|
|
||||||
<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>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)
|
</div>
|
||||||
})}
|
)
|
||||||
</div>
|
})}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useParams } from 'next/navigation'
|
import { useParams } from 'next/navigation'
|
||||||
|
import { SuggestedNorms } from './_components/SuggestedNorms'
|
||||||
|
|
||||||
interface ProjectOverview {
|
interface ProjectOverview {
|
||||||
id: string
|
id: string
|
||||||
@@ -14,12 +15,12 @@ interface ProjectOverview {
|
|||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
gates: Gate[]
|
gates: Gate[]
|
||||||
risk_summary: {
|
risk_summary?: {
|
||||||
critical: number
|
critical?: number
|
||||||
high: number
|
high?: number
|
||||||
medium: number
|
medium?: number
|
||||||
low: number
|
low?: number
|
||||||
total: number
|
total?: number
|
||||||
}
|
}
|
||||||
component_count: number
|
component_count: number
|
||||||
hazard_count: number
|
hazard_count: number
|
||||||
@@ -120,11 +121,72 @@ export default function ProjectOverviewPage() {
|
|||||||
|
|
||||||
async function fetchProject() {
|
async function fetchProject() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}`)
|
// Fetch project detail + live risk summary + mitigations count in parallel
|
||||||
if (res.ok) {
|
const [projRes, riskRes, mitRes, hazRes] = await Promise.all([
|
||||||
const json = await res.json()
|
fetch(`/api/sdk/v1/iace/projects/${projectId}`),
|
||||||
setProject(json)
|
fetch(`/api/sdk/v1/iace/projects/${projectId}/risk-summary`),
|
||||||
|
fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations`),
|
||||||
|
fetch(`/api/sdk/v1/iace/projects/${projectId}/hazards`),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (!projRes.ok) return
|
||||||
|
const json = await projRes.json()
|
||||||
|
|
||||||
|
// Live risk summary from dedicated endpoint
|
||||||
|
let rs = json.risk_summary || {}
|
||||||
|
if (riskRes.ok) {
|
||||||
|
const riskJson = await riskRes.json()
|
||||||
|
const live = riskJson.risk_summary || riskJson || {}
|
||||||
|
rs = {
|
||||||
|
critical: live.critical || 0,
|
||||||
|
high: live.high || 0,
|
||||||
|
medium: live.medium || 0,
|
||||||
|
low: live.low || 0,
|
||||||
|
negligible: live.negligible || 0,
|
||||||
|
total: live.total_hazards || live.total || 0,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Live counts
|
||||||
|
let mitCount = 0
|
||||||
|
if (mitRes.ok) {
|
||||||
|
const mitJson = await mitRes.json()
|
||||||
|
mitCount = mitJson.total || (mitJson.mitigations || []).length || 0
|
||||||
|
}
|
||||||
|
let hazCount = 0
|
||||||
|
if (hazRes.ok) {
|
||||||
|
const hazJson = await hazRes.json()
|
||||||
|
hazCount = hazJson.total || (hazJson.hazards || []).length || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate dynamic completeness percentage
|
||||||
|
const compCount = json.components?.length || 0
|
||||||
|
const gates = (json.completeness_gates || json.gates || [])
|
||||||
|
const gatesPassed = gates.filter((g: Record<string, unknown>) => g.passed === true).length
|
||||||
|
const gatesTotal = gates.length || 1
|
||||||
|
const completeness = Math.round((gatesPassed / gatesTotal) * 100)
|
||||||
|
|
||||||
|
setProject({
|
||||||
|
...json,
|
||||||
|
completeness_pct: completeness,
|
||||||
|
component_count: compCount,
|
||||||
|
hazard_count: hazCount,
|
||||||
|
mitigation_count: mitCount,
|
||||||
|
risk_summary: {
|
||||||
|
critical: rs.critical || 0,
|
||||||
|
high: rs.high || 0,
|
||||||
|
medium: rs.medium || 0,
|
||||||
|
low: rs.low || 0,
|
||||||
|
total: rs.total || hazCount,
|
||||||
|
},
|
||||||
|
gates: gates.map((g: Record<string, unknown>) => ({
|
||||||
|
id: g.id,
|
||||||
|
name: g.name || g.label || '',
|
||||||
|
description: g.description || g.details || '',
|
||||||
|
passed: g.passed,
|
||||||
|
required: g.required,
|
||||||
|
})),
|
||||||
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch project:', err)
|
console.error('Failed to fetch project:', err)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -229,15 +291,31 @@ export default function ProjectOverviewPage() {
|
|||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Risk Summary */}
|
{/* Risk Summary — live from /risk-summary endpoint */}
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||||
<h2 className="text-sm font-semibold text-gray-900 dark:text-white mb-4">Risikozusammenfassung</h2>
|
<h2 className="text-sm font-semibold text-gray-900 dark:text-white mb-4">Risikozusammenfassung</h2>
|
||||||
<div className="flex items-center justify-around">
|
{/* Risk level bars */}
|
||||||
<RiskGauge label="Kritisch" value={project.risk_summary.critical} max={project.risk_summary.total || 1} color="#EF4444" />
|
<div className="space-y-2">
|
||||||
<RiskGauge label="Hoch" value={project.risk_summary.high} max={project.risk_summary.total || 1} color="#F97316" />
|
{[
|
||||||
<RiskGauge label="Mittel" value={project.risk_summary.medium} max={project.risk_summary.total || 1} color="#EAB308" />
|
{ label: 'Kritisch', value: project.risk_summary?.critical || 0, color: 'bg-red-500', text: 'text-red-700' },
|
||||||
<RiskGauge label="Niedrig" value={project.risk_summary.low} max={project.risk_summary.total || 1} color="#22C55E" />
|
{ label: 'Hoch', value: project.risk_summary?.high || 0, color: 'bg-orange-500', text: 'text-orange-700' },
|
||||||
|
{ label: 'Mittel', value: project.risk_summary?.medium || 0, color: 'bg-yellow-500', text: 'text-yellow-700' },
|
||||||
|
{ label: 'Niedrig', value: project.risk_summary?.low || 0, color: 'bg-green-500', text: 'text-green-700' },
|
||||||
|
].map((level) => {
|
||||||
|
const total = project.risk_summary?.total || 1
|
||||||
|
const pct = Math.round((level.value / total) * 100)
|
||||||
|
return (
|
||||||
|
<div key={level.label} className="flex items-center gap-3">
|
||||||
|
<span className={`text-xs font-medium w-16 ${level.text}`}>{level.label}</span>
|
||||||
|
<div className="flex-1 bg-gray-100 rounded-full h-4 overflow-hidden">
|
||||||
|
<div className={`${level.color} h-4 rounded-full transition-all`} style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-bold text-gray-900 dark:text-white w-8 text-right">{level.value}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
{/* Counts */}
|
||||||
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700 grid grid-cols-3 gap-4 text-center">
|
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700 grid grid-cols-3 gap-4 text-center">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">{project.component_count}</div>
|
<div className="text-2xl font-bold text-gray-900 dark:text-white">{project.component_count}</div>
|
||||||
@@ -252,6 +330,10 @@ export default function ProjectOverviewPage() {
|
|||||||
<div className="text-xs text-gray-500">Massnahmen</div>
|
<div className="text-xs text-gray-500">Massnahmen</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* RPZ threshold info */}
|
||||||
|
<div className="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700 text-xs text-gray-500">
|
||||||
|
RPZ-Schwellen: Kritisch >100 | Hoch 60-100 | Mittel 20-60 | Niedrig <20
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Completeness Gates */}
|
{/* Completeness Gates */}
|
||||||
@@ -267,6 +349,9 @@ export default function ProjectOverviewPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Suggested Norms */}
|
||||||
|
<SuggestedNorms projectId={projectId} />
|
||||||
|
|
||||||
{/* Quick Actions */}
|
{/* Quick Actions */}
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-sm font-semibold text-gray-900 dark:text-white mb-4">Schnellzugriff</h2>
|
<h2 className="text-sm font-semibold text-gray-900 dark:text-white mb-4">Schnellzugriff</h2>
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
|
||||||
|
interface NormStats {
|
||||||
|
total: number
|
||||||
|
byType: Record<string, number>
|
||||||
|
categories: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const TYPE_INFO: Record<string, { label: string; color: string }> = {
|
||||||
|
A: { label: 'A-Normen (Grundnormen)', color: 'bg-red-50 text-red-800 border-red-200' },
|
||||||
|
B1: { label: 'B1-Normen (Sicherheitsgrundnormen)', color: 'bg-blue-50 text-blue-800 border-blue-200' },
|
||||||
|
B2: { label: 'B2-Normen (Sicherheitsfachgrundnormen)', color: 'bg-green-50 text-green-800 border-green-200' },
|
||||||
|
C: { label: 'C-Normen (Maschinenspezifisch)', color: 'bg-purple-50 text-purple-800 border-purple-200' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const CATEGORY_DESCRIPTIONS: Record<string, string> = {
|
||||||
|
A: 'ISO 12100 (Grundnorm Risikobeurteilung)',
|
||||||
|
B1: 'ISO 13849-1/2, IEC 62061, IEC 61508 (SIL/PL, Funktionale Sicherheit)',
|
||||||
|
B2: 'Elektrik, Ergonomie, Vibration, Laerm, Brandschutz, Hydraulik/Pneumatik, Software-Safety, Emissionen, Schutzeinrichtungen, Zugaenge, Signale',
|
||||||
|
C: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NormsCoverage() {
|
||||||
|
const [stats, setStats] = useState<NormStats | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [expanded, setExpanded] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/sdk/v1/iace/norms-library')
|
||||||
|
.then((r) => (r.ok ? r.json() : null))
|
||||||
|
.then((json) => {
|
||||||
|
if (!json?.norms) return
|
||||||
|
const norms = json.norms as Array<{ norm_type: string; machine_types?: string[] }>
|
||||||
|
const byType: Record<string, number> = {}
|
||||||
|
const machineTypes = new Set<string>()
|
||||||
|
for (const n of norms) {
|
||||||
|
byType[n.norm_type] = (byType[n.norm_type] || 0) + 1
|
||||||
|
if (n.machine_types) {
|
||||||
|
for (const mt of n.machine_types) machineTypes.add(mt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Group machine types into readable categories
|
||||||
|
const catMap: Record<string, string> = {
|
||||||
|
press: 'Pressen', hydraulic_press: 'Pressen', mechanical_press: 'Pressen', press_brake: 'Pressen',
|
||||||
|
robot: 'Roboter', industrial_robot: 'Roboter', robot_cell: 'Roboter',
|
||||||
|
collaborative_robot: 'Kollaborierende Roboter', cobot: 'Kollaborierende Roboter',
|
||||||
|
woodworking: 'Holzbearbeitung', saw: 'Holzbearbeitung', circular_saw: 'Holzbearbeitung',
|
||||||
|
panel_saw: 'Holzbearbeitung', table_saw: 'Holzbearbeitung', miter_saw: 'Holzbearbeitung',
|
||||||
|
log_saw: 'Holzbearbeitung', planer: 'Holzbearbeitung', router: 'Holzbearbeitung',
|
||||||
|
lathe: 'Metallbearbeitung', turning_machine: 'Metallbearbeitung', large_lathe: 'Metallbearbeitung',
|
||||||
|
small_lathe: 'Metallbearbeitung', milling_machine: 'Metallbearbeitung', drilling_machine: 'Metallbearbeitung',
|
||||||
|
grinding_machine: 'Metallbearbeitung', metal_saw: 'Metallbearbeitung', band_saw: 'Metallbearbeitung',
|
||||||
|
cold_saw: 'Metallbearbeitung', shearing_machine: 'Metallbearbeitung', bending_machine: 'Metallbearbeitung',
|
||||||
|
cnc: 'Metallbearbeitung', machining_center: 'Metallbearbeitung', transfer_machine: 'Metallbearbeitung',
|
||||||
|
injection_molding: 'Kunststoff/Gummi', plastics_machine: 'Kunststoff/Gummi',
|
||||||
|
compression_molding: 'Kunststoff/Gummi', blow_molding: 'Kunststoff/Gummi',
|
||||||
|
extruder: 'Kunststoff/Gummi', plastics_press: 'Kunststoff/Gummi',
|
||||||
|
rubber_machine: 'Kunststoff/Gummi', two_roll_mill: 'Kunststoff/Gummi',
|
||||||
|
reaction_molding: 'Kunststoff/Gummi', calender: 'Kunststoff/Gummi',
|
||||||
|
food_machine: 'Lebensmittel', meat_grinder: 'Lebensmittel', bread_slicer: 'Lebensmittel',
|
||||||
|
bakery: 'Lebensmittel', mixer: 'Lebensmittel', cooker: 'Lebensmittel',
|
||||||
|
cutter: 'Lebensmittel', food_cutter: 'Lebensmittel', filling_machine: 'Lebensmittel',
|
||||||
|
packaging_machine: 'Verpackung', palletizer: 'Verpackung', pallet_wrapper: 'Verpackung',
|
||||||
|
wrapping_machine: 'Verpackung', strapping_machine: 'Verpackung',
|
||||||
|
textile_machine: 'Textilmaschinen', spinning_machine: 'Textilmaschinen',
|
||||||
|
weaving_machine: 'Textilmaschinen', dyeing_machine: 'Textilmaschinen',
|
||||||
|
nonwoven_machine: 'Textilmaschinen',
|
||||||
|
agricultural_machine: 'Landmaschinen', combine_harvester: 'Landmaschinen',
|
||||||
|
mower: 'Landmaschinen', baler: 'Landmaschinen', sprayer: 'Landmaschinen', tiller: 'Landmaschinen',
|
||||||
|
crane: 'Krane/Hebezeuge', bridge_crane: 'Krane/Hebezeuge', gantry_crane: 'Krane/Hebezeuge',
|
||||||
|
tower_crane: 'Krane/Hebezeuge', mobile_crane: 'Krane/Hebezeuge', hoist: 'Krane/Hebezeuge',
|
||||||
|
winch: 'Krane/Hebezeuge', slewing_crane: 'Krane/Hebezeuge',
|
||||||
|
elevator: 'Aufzuege', lift: 'Aufzuege', construction_hoist: 'Aufzuege',
|
||||||
|
conveyor: 'Foerdertechnik', belt_conveyor: 'Foerdertechnik', screw_conveyor: 'Foerdertechnik',
|
||||||
|
transfer_system: 'Foerdertechnik', rotary_transfer_machine: 'Foerdertechnik',
|
||||||
|
forklift: 'Flurfoerderzeuge', industrial_truck: 'Flurfoerderzeuge',
|
||||||
|
earth_moving: 'Erdbaumaschinen', excavator: 'Erdbaumaschinen',
|
||||||
|
wheel_loader: 'Erdbaumaschinen', bulldozer: 'Erdbaumaschinen',
|
||||||
|
welding_machine: 'Schweissmaschinen', arc_welder: 'Schweissmaschinen',
|
||||||
|
printing_press: 'Druckmaschinen', coating_machine: 'Druckmaschinen',
|
||||||
|
pump: 'Pumpen/Kompressoren', compressor: 'Pumpen/Kompressoren', vacuum_pump: 'Pumpen/Kompressoren',
|
||||||
|
foundry_machine: 'Giesserei', casting_machine: 'Giesserei', die_casting: 'Giesserei',
|
||||||
|
industrial_furnace: 'Industrieoefen', heat_treatment: 'Industrieoefen',
|
||||||
|
dryer: 'Trockner/Oefen', oven: 'Trockner/Oefen', kiln: 'Trockner/Oefen',
|
||||||
|
paper_machine: 'Papiermaschinen', slitter_rewinder: 'Papiermaschinen', pulper: 'Papiermaschinen',
|
||||||
|
centrifuge: 'Zentrifugen',
|
||||||
|
aerial_platform: 'Hubarbeitsbuehnen', cherry_picker: 'Hubarbeitsbuehnen',
|
||||||
|
scissor_lift: 'Hubtische', lift_table: 'Hubtische',
|
||||||
|
powered_gate: 'Tore/Tueren', industrial_door: 'Tore/Tueren',
|
||||||
|
laser_machine: 'Lasermaschinen', laser_cutter: 'Lasermaschinen',
|
||||||
|
silo: 'Schuettgutanlagen', bunker: 'Schuettgutanlagen',
|
||||||
|
suspended_platform: 'Haengebuehnen', scaffold: 'Haengebuehnen',
|
||||||
|
storage_retrieval: 'Lagertechnik', automated_warehouse: 'Lagertechnik',
|
||||||
|
pressure_vessel: 'Druckbehaelter', hydraulic_accumulator: 'Druckbehaelter',
|
||||||
|
}
|
||||||
|
const cats = new Set<string>()
|
||||||
|
for (const mt of machineTypes) {
|
||||||
|
cats.add(catMap[mt] || mt)
|
||||||
|
}
|
||||||
|
const sortedCats = Array.from(cats).sort()
|
||||||
|
setStats({ total: norms.length, byType, categories: sortedCats })
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (loading || !stats) return null
|
||||||
|
|
||||||
|
const cDesc = stats.categories.join(', ')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 bg-purple-50 dark:bg-purple-900/10 border border-purple-200 dark:border-purple-800 rounded-lg">
|
||||||
|
<button onClick={() => setExpanded(!expanded)} className="w-full text-left">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<svg className="w-5 h-5 text-purple-600" 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>
|
||||||
|
<span className="text-sm font-semibold text-purple-800 dark:text-purple-300">
|
||||||
|
Normen-Bibliothek: {stats.total} Normen in {stats.categories.length} Branchen
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<svg className={`w-4 h-4 text-purple-400 transition-transform ${expanded ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{expanded && (
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-purple-200 dark:border-purple-700">
|
||||||
|
<th className="text-left py-1.5 px-2 font-semibold text-purple-800 dark:text-purple-300 w-48">Typ</th>
|
||||||
|
<th className="text-center py-1.5 px-2 font-semibold text-purple-800 dark:text-purple-300 w-16">Anzahl</th>
|
||||||
|
<th className="text-left py-1.5 px-2 font-semibold text-purple-800 dark:text-purple-300">Abdeckung</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(['A', 'B1', 'B2', 'C'] as const).map((type) => {
|
||||||
|
const info = TYPE_INFO[type]
|
||||||
|
const count = stats.byType[type] || 0
|
||||||
|
const desc = type === 'C' ? cDesc : CATEGORY_DESCRIPTIONS[type]
|
||||||
|
return (
|
||||||
|
<tr key={type} className="border-b border-purple-100 dark:border-purple-800/50">
|
||||||
|
<td className="py-2 px-2">
|
||||||
|
<span className={`inline-block px-2 py-0.5 rounded border text-xs font-medium ${info.color}`}>
|
||||||
|
{info.label}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-2 text-center font-bold text-purple-900 dark:text-purple-200">{count}</td>
|
||||||
|
<td className="py-2 px-2 text-gray-700 dark:text-gray-300">{desc}</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div className="pt-2 text-xs text-purple-600 dark:text-purple-400">
|
||||||
|
Alle Normen mit Abschnittsnummern und{' '}
|
||||||
|
<a href="https://www.beuth.de" target="_blank" rel="noopener noreferrer" className="underline font-medium hover:text-purple-800">
|
||||||
|
Beuth-Kauflinks
|
||||||
|
</a>{' '}
|
||||||
|
hinterlegt. Die vollstaendige Bibliothek ist unter "Normenrecherche" in jedem Projekt einsehbar.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { NormsCoverage } from './NormsCoverage'
|
||||||
|
|
||||||
|
type ScopeStatus = 'in_scope' | 'partially' | 'not_in_scope' | 'planned'
|
||||||
|
|
||||||
|
interface ProcessStep {
|
||||||
|
number: number
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
actor: string
|
||||||
|
scope: ScopeStatus
|
||||||
|
toolNote?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const CE_PROCESS_STEPS: ProcessStep[] = [
|
||||||
|
{
|
||||||
|
number: 1,
|
||||||
|
title: 'Maschinenplanung',
|
||||||
|
description: 'Hersteller plant Maschine/Anlage',
|
||||||
|
actor: 'Hersteller',
|
||||||
|
scope: 'not_in_scope',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
number: 2,
|
||||||
|
title: 'CE-Firma beauftragen',
|
||||||
|
description: 'Hersteller beauftragt CE-Beratungsfirma oder internes CE-Team',
|
||||||
|
actor: 'Hersteller',
|
||||||
|
scope: 'not_in_scope',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
number: 3,
|
||||||
|
title: 'Grenzen definieren',
|
||||||
|
description:
|
||||||
|
'Bestimmungsgemasse Verwendung, vorhersehbare Fehlanwendung, Betriebsarten, raeumliche/zeitliche Grenzen',
|
||||||
|
actor: 'Gemeinsam',
|
||||||
|
scope: 'in_scope',
|
||||||
|
toolNote: 'Interview/Wizard tab',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
number: 4,
|
||||||
|
title: 'Normenrecherche',
|
||||||
|
description:
|
||||||
|
'C-Normen (maschinenspezifisch), B-Normen (Sicherheitsfunktionen), A-Normen (ISO 12100). Harmonisierte Normen ermoeglichen Konformitaetsvermutung.',
|
||||||
|
actor: 'CE-Firma',
|
||||||
|
scope: 'in_scope',
|
||||||
|
toolNote: 'manueller Eintrag',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
number: 5,
|
||||||
|
title: 'Maschinenbeschreibung',
|
||||||
|
description:
|
||||||
|
'Komponentenbaum, Energiequellen, technische Daten, Betriebsarten systematisch erfassen',
|
||||||
|
actor: 'CE-Firma',
|
||||||
|
scope: 'in_scope',
|
||||||
|
toolNote: 'Komponenten tab',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
number: 6,
|
||||||
|
title: 'Gefaehrdungen identifizieren',
|
||||||
|
description:
|
||||||
|
'Systematisch pro Komponente x Lebenszyklus. Deterministisches Pattern-Matching generiert Vorschlaege.',
|
||||||
|
actor: 'CE-Firma + Tool',
|
||||||
|
scope: 'in_scope',
|
||||||
|
toolNote: 'Hazard Log',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
number: 7,
|
||||||
|
title: 'Risiko bewerten',
|
||||||
|
description:
|
||||||
|
'Schwere x Exposition x Eintrittswahrscheinlichkeit. Automatische SIL/PL-Ableitung aus Risikograph.',
|
||||||
|
actor: 'CE-Firma + Tool',
|
||||||
|
scope: 'in_scope',
|
||||||
|
toolNote: 'Hazard Log',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
number: 8,
|
||||||
|
title: 'Massnahmen definieren',
|
||||||
|
description:
|
||||||
|
'3-Stufen-Hierarchie (PFLICHT): 1. Design, 2. Schutzeinrichtung, 3. Information. Tool schlaegt kategorienspezifisch vor.',
|
||||||
|
actor: 'CE-Firma + Tool',
|
||||||
|
scope: 'in_scope',
|
||||||
|
toolNote: 'Massnahmen tab',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
number: 9,
|
||||||
|
title: 'Massnahmen umsetzen',
|
||||||
|
description:
|
||||||
|
'Hersteller implementiert konstruktive Aenderungen, Schutzeinrichtungen, Beschilderung etc.',
|
||||||
|
actor: 'Hersteller',
|
||||||
|
scope: 'partially',
|
||||||
|
toolNote: 'Nachweis-Upload',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
number: 10,
|
||||||
|
title: 'Restrisiko bewerten',
|
||||||
|
description:
|
||||||
|
'Iterativ: Nach Massnahmen-Umsetzung erneut bewerten. Akzeptabel? Wenn nein: zurueck zu Schritt 8.',
|
||||||
|
actor: 'CE-Firma',
|
||||||
|
scope: 'in_scope',
|
||||||
|
toolNote: 'Reassessment',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
number: 11,
|
||||||
|
title: 'Verifikation',
|
||||||
|
description: 'Messungen, Berechnungen, Pruefungen. Nachweise den Massnahmen zuordnen.',
|
||||||
|
actor: 'CE-Firma',
|
||||||
|
scope: 'in_scope',
|
||||||
|
toolNote: 'Verifikation tab',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
number: 12,
|
||||||
|
title: 'Benannte Stelle',
|
||||||
|
description:
|
||||||
|
'NUR fuer Annex-IV-Maschinen (Pressen, Holzbearbeitung, Hebezeuge): Formale Baumusterpruefung durch TUeV/DGUV Test o.ae.',
|
||||||
|
actor: 'Notified Body',
|
||||||
|
scope: 'not_in_scope',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
number: 13,
|
||||||
|
title: 'Betriebsanleitung',
|
||||||
|
description:
|
||||||
|
'Restrisiken fuer Bediener dokumentieren, Sicherheitshinweise, bestimmungsgemasse Verwendung',
|
||||||
|
actor: 'CE-Firma',
|
||||||
|
scope: 'planned',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
number: 14,
|
||||||
|
title: 'Technische Unterlagen',
|
||||||
|
description:
|
||||||
|
'Gesamtdossier: Plaene, Schaltbilder, Berechnungen, Risikobeurteilung, Normen, Pruefberichte, Betriebsanleitung',
|
||||||
|
actor: 'CE-Firma',
|
||||||
|
scope: 'in_scope',
|
||||||
|
toolNote: 'CE-Akte tab',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
number: 15,
|
||||||
|
title: 'CE-Erklaerung',
|
||||||
|
description:
|
||||||
|
'Hersteller unterschreibt EU-Konformitaetserklaerung und bringt CE-Kennzeichnung an. Die CE-Firma gibt KEIN CE — der Hersteller traegt die Verantwortung.',
|
||||||
|
actor: 'Hersteller',
|
||||||
|
scope: 'not_in_scope',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const SCOPE_STYLES: Record<ScopeStatus, { border: string; bg: string; badge: string; badgeText: string }> = {
|
||||||
|
in_scope: {
|
||||||
|
border: 'border-l-purple-500',
|
||||||
|
bg: 'bg-purple-50 dark:bg-purple-900/10',
|
||||||
|
badge: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300',
|
||||||
|
badgeText: 'Im Tool',
|
||||||
|
},
|
||||||
|
partially: {
|
||||||
|
border: 'border-l-yellow-500',
|
||||||
|
bg: 'bg-yellow-50 dark:bg-yellow-900/10',
|
||||||
|
badge: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300',
|
||||||
|
badgeText: 'Teilweise',
|
||||||
|
},
|
||||||
|
not_in_scope: {
|
||||||
|
border: 'border-l-gray-300',
|
||||||
|
bg: 'bg-gray-50 dark:bg-gray-800/50',
|
||||||
|
badge: 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400',
|
||||||
|
badgeText: 'Nicht im Tool',
|
||||||
|
},
|
||||||
|
planned: {
|
||||||
|
border: 'border-l-gray-300 border-dashed',
|
||||||
|
bg: 'bg-gray-50 dark:bg-gray-800/50',
|
||||||
|
badge: 'bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-300',
|
||||||
|
badgeText: 'Geplant',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'iace-process-flow-collapsed'
|
||||||
|
|
||||||
|
function StepCard({ step }: { step: ProcessStep }) {
|
||||||
|
const style = SCOPE_STYLES[step.scope]
|
||||||
|
const muted = step.scope === 'not_in_scope' || step.scope === 'planned'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`relative flex gap-4 ${muted ? 'opacity-75' : ''}`}>
|
||||||
|
{/* Timeline connector */}
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<div
|
||||||
|
className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0 ${
|
||||||
|
step.scope === 'in_scope'
|
||||||
|
? 'bg-purple-600 text-white'
|
||||||
|
: step.scope === 'partially'
|
||||||
|
? 'bg-yellow-500 text-white'
|
||||||
|
: 'bg-gray-300 text-gray-600 dark:bg-gray-600 dark:text-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{step.number}
|
||||||
|
</div>
|
||||||
|
{step.number < 15 && (
|
||||||
|
<div className="w-0.5 flex-1 bg-gray-200 dark:bg-gray-700 mt-1" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card */}
|
||||||
|
<div
|
||||||
|
className={`flex-1 mb-3 p-4 rounded-lg border-l-4 ${style.border} ${style.bg} ${
|
||||||
|
step.scope === 'planned' ? 'border-dashed border border-gray-300 dark:border-gray-600' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2 mb-1">
|
||||||
|
<h4 className={`font-semibold text-sm ${muted ? 'text-gray-600 dark:text-gray-400' : 'text-gray-900 dark:text-white'}`}>
|
||||||
|
{step.title}
|
||||||
|
</h4>
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${style.badge}`}>
|
||||||
|
{style.badgeText}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className={`text-xs leading-relaxed ${muted ? 'text-gray-500 dark:text-gray-500' : 'text-gray-700 dark:text-gray-300'}`}>
|
||||||
|
{step.description}
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 flex items-center gap-3">
|
||||||
|
<span className="inline-flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<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>
|
||||||
|
{step.actor}
|
||||||
|
</span>
|
||||||
|
{step.toolNote && (
|
||||||
|
<span className="inline-flex items-center gap-1 text-xs text-purple-600 dark:text-purple-400">
|
||||||
|
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||||
|
</svg>
|
||||||
|
{step.toolNote}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProcessFlow() {
|
||||||
|
// Default to expanded (false) — avoids SSR hydration mismatch
|
||||||
|
const [collapsed, setCollapsed] = useState(false)
|
||||||
|
const [mounted, setMounted] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (stored === 'true') {
|
||||||
|
setCollapsed(true)
|
||||||
|
}
|
||||||
|
setMounted(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
const next = !collapsed
|
||||||
|
setCollapsed(next)
|
||||||
|
localStorage.setItem(STORAGE_KEY, String(next))
|
||||||
|
}
|
||||||
|
|
||||||
|
const inScopeCount = CE_PROCESS_STEPS.filter((s) => s.scope === 'in_scope').length
|
||||||
|
const partialCount = CE_PROCESS_STEPS.filter((s) => s.scope === 'partially').length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
|
{/* Header — always visible */}
|
||||||
|
<button
|
||||||
|
onClick={toggle}
|
||||||
|
className="w-full flex items-center justify-between px-6 py-4 text-left hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-semibold text-gray-900 dark:text-white">
|
||||||
|
CE-Prozess: 15 Schritte zur Konformitaet
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||||
|
{inScopeCount} Schritte im Tool abgedeckt, {partialCount} teilweise
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<svg
|
||||||
|
className={`w-5 h-5 text-gray-400 transition-transform ${collapsed ? '' : '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>
|
||||||
|
|
||||||
|
{/* Content — collapsible */}
|
||||||
|
{!collapsed && (
|
||||||
|
<div className="px-6 pb-6 border-t border-gray-100 dark:border-gray-700">
|
||||||
|
{/* Legend */}
|
||||||
|
<div className="flex flex-wrap items-center gap-4 py-3 mb-4">
|
||||||
|
<span className="flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-300">
|
||||||
|
<span className="w-3 h-3 rounded-sm bg-purple-500" />
|
||||||
|
Im Tool abgedeckt
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-300">
|
||||||
|
<span className="w-3 h-3 rounded-sm bg-yellow-500" />
|
||||||
|
Teilweise abgedeckt
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-300">
|
||||||
|
<span className="w-3 h-3 rounded-sm bg-gray-300 dark:bg-gray-600" />
|
||||||
|
Nicht im Tool
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-300">
|
||||||
|
<span className="w-3 h-3 rounded-sm border border-dashed border-gray-400" />
|
||||||
|
Geplant
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timeline */}
|
||||||
|
<div className="space-y-0">
|
||||||
|
{CE_PROCESS_STEPS.map((step) => (
|
||||||
|
<StepCard key={step.number} step={step} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Norms Coverage Table */}
|
||||||
|
<div className="mt-4">
|
||||||
|
<NormsCoverage />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Disclaimers */}
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
<div className="p-3 bg-amber-50 dark:bg-amber-900/10 border border-amber-200 dark:border-amber-800 rounded-lg">
|
||||||
|
<p className="text-xs text-amber-800 dark:text-amber-300 leading-relaxed">
|
||||||
|
<strong>Hinweis:</strong> Dieses Tool ersetzt NICHT die Fachkompetenz eines CE-Beraters.
|
||||||
|
Es automatisiert die systematische Dokumentation und schlaegt Gefaehrdungen/Massnahmen vor.
|
||||||
|
Die fachliche Bewertung und Verantwortung verbleibt beim CE-Experten und Hersteller.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-3 bg-blue-50 dark:bg-blue-900/10 border border-blue-200 dark:border-blue-800 rounded-lg">
|
||||||
|
<p className="text-xs font-semibold text-blue-800 dark:text-blue-300 mb-2">Normenrecherche — Rechtliche Grundlage</p>
|
||||||
|
<div className="text-xs text-blue-700 dark:text-blue-400 leading-relaxed space-y-2">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium mb-1">Was dieses Tool anzeigt:</p>
|
||||||
|
<ul className="list-disc list-inside space-y-0.5 ml-1">
|
||||||
|
<li>Normennummern (z.B. "ISO 13857:2019") — Identifikatoren, kein geschuetzter Text</li>
|
||||||
|
<li>Offizielle Normentitel — bibliografische Information</li>
|
||||||
|
<li>Abschnittsnummern (z.B. "Abschnitt 4.2, Tabelle 1") — Verweisadressen</li>
|
||||||
|
<li>Eigene Zusammenfassungen des Regelungsbereichs — unser Text, nicht Normtext</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium mb-1">Was dieses Tool NICHT anzeigt:</p>
|
||||||
|
<ul className="list-disc list-inside space-y-0.5 ml-1">
|
||||||
|
<li>Normtext (auch nicht auszugsweise) — urheberrechtlich geschuetzt durch DIN/ISO/CEN</li>
|
||||||
|
<li>Tabellenwerte oder Grenzwerte aus Normen</li>
|
||||||
|
<li>Diagramme oder Bilder aus Normen</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<p className="text-blue-600 dark:text-blue-300 pt-1">
|
||||||
|
Normtexte muessen separat beschafft werden, z.B. ueber{' '}
|
||||||
|
<a href="https://www.beuth.de" target="_blank" rel="noopener noreferrer" className="underline font-medium hover:text-blue-800">
|
||||||
|
www.beuth.de
|
||||||
|
</a>{' '}
|
||||||
|
(DIN-Normen) oder{' '}
|
||||||
|
<a href="https://www.iso.org" target="_blank" rel="noopener noreferrer" className="underline font-medium hover:text-blue-800">
|
||||||
|
www.iso.org
|
||||||
|
</a>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { usePathname, useParams } from 'next/navigation'
|
import { usePathname, useParams } from 'next/navigation'
|
||||||
|
import IACEFlowFAB from './[projectId]/_components/IACEFlowFAB'
|
||||||
|
|
||||||
const IACE_NAV_ITEMS = [
|
const IACE_NAV_ITEMS = [
|
||||||
{ id: 'overview', label: 'Uebersicht', href: '', icon: 'grid' },
|
{ id: 'overview', label: 'Uebersicht', href: '', icon: 'grid' },
|
||||||
@@ -112,6 +113,15 @@ export default function IACELayout({ children }: { children: React.ReactNode })
|
|||||||
<h2 className="text-sm font-semibold text-gray-900 dark:text-white mt-2">
|
<h2 className="text-sm font-semibold text-gray-900 dark:text-white mt-2">
|
||||||
CE-Compliance
|
CE-Compliance
|
||||||
</h2>
|
</h2>
|
||||||
|
<Link
|
||||||
|
href="/sdk/iace/lines"
|
||||||
|
className="mt-2 flex items-center gap-1.5 text-xs text-gray-500 hover:text-purple-600 dark:text-gray-400 dark:hover:text-purple-400 transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<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>
|
||||||
|
Produktionslinien
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<nav className="p-2 space-y-0.5">
|
<nav className="p-2 space-y-0.5">
|
||||||
{IACE_NAV_ITEMS.map((item) => (
|
{IACE_NAV_ITEMS.map((item) => (
|
||||||
@@ -136,6 +146,9 @@ export default function IACELayout({ children }: { children: React.ReactNode })
|
|||||||
<main className="flex-1 overflow-auto bg-gray-50 dark:bg-gray-900">
|
<main className="flex-1 overflow-auto bg-gray-50 dark:bg-gray-900">
|
||||||
<div className="p-6">{children}</div>
|
<div className="p-6">{children}</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{/* CE Process Step Navigator FAB */}
|
||||||
|
{projectId && <IACEFlowFAB />}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
import type { LineDashboard } from '../../_types'
|
||||||
|
|
||||||
|
interface AggregatePanelProps {
|
||||||
|
dashboard: LineDashboard
|
||||||
|
}
|
||||||
|
|
||||||
|
const RISK_DOTS = [
|
||||||
|
{ key: 'critical', label: 'Kritisch', dotColor: 'bg-red-500' },
|
||||||
|
{ key: 'high', label: 'Hoch', dotColor: 'bg-orange-500' },
|
||||||
|
{ key: 'medium', label: 'Mittel', dotColor: 'bg-yellow-500' },
|
||||||
|
{ key: 'low', label: 'Niedrig', dotColor: 'bg-green-500' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function AggregatePanel({ dashboard }: AggregatePanelProps) {
|
||||||
|
const { line, stations, aggregate } = dashboard
|
||||||
|
|
||||||
|
const totalHazards = stations.reduce((sum, s) => sum + s.hazard_count, 0)
|
||||||
|
const totalMitigations = stations.reduce((sum, s) => sum + s.mitigation_count, 0)
|
||||||
|
const stationCount = stations.length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||||
|
{/* Title row */}
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{line.name}
|
||||||
|
</h1>
|
||||||
|
{line.description && (
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
||||||
|
{line.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-gray-400 dark:text-gray-500">
|
||||||
|
<span>Erstellt: {new Date(line.created_at).toLocaleDateString('de-DE')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats row */}
|
||||||
|
<div className="flex flex-wrap items-center gap-6 mb-3">
|
||||||
|
<StatPill label="Stationen" value={stationCount} />
|
||||||
|
<StatPill label="Gefaehrdungen" value={totalHazards} />
|
||||||
|
<StatPill label="Massnahmen" value={totalMitigations} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Risk dots row */}
|
||||||
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
|
{RISK_DOTS.map((rd) => {
|
||||||
|
const count = aggregate[rd.key] || 0
|
||||||
|
return (
|
||||||
|
<span key={rd.key} className="flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-300">
|
||||||
|
<span className={`w-2.5 h-2.5 rounded-full ${rd.dotColor}`} />
|
||||||
|
<span className="font-semibold">{count}</span>
|
||||||
|
<span>{rd.label}</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatPill({ label, value }: { label: string; value: number }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5 text-sm">
|
||||||
|
<span className="font-bold text-gray-900 dark:text-white">{value}</span>
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">{label}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { StationIcon } from './StationIcons'
|
||||||
|
import { STATION_TYPES } from '../../_types'
|
||||||
|
import type { StationDashboard } from '../../_types'
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
|
draft: 'bg-gray-100 text-gray-700',
|
||||||
|
in_progress: 'bg-blue-100 text-blue-700',
|
||||||
|
review: 'bg-yellow-100 text-yellow-700',
|
||||||
|
approved: 'bg-green-100 text-green-700',
|
||||||
|
archived: 'bg-gray-100 text-gray-500',
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
|
draft: 'Entwurf',
|
||||||
|
in_progress: 'In Bearbeitung',
|
||||||
|
review: 'In Pruefung',
|
||||||
|
approved: 'Freigegeben',
|
||||||
|
archived: 'Archiviert',
|
||||||
|
}
|
||||||
|
|
||||||
|
const RISK_LEVELS = [
|
||||||
|
{ key: 'critical', label: 'Kritisch', color: 'bg-red-500', text: 'text-red-700' },
|
||||||
|
{ key: 'high', label: 'Hoch', color: 'bg-orange-500', text: 'text-orange-700' },
|
||||||
|
{ key: 'medium', label: 'Mittel', color: 'bg-yellow-500', text: 'text-yellow-700' },
|
||||||
|
{ key: 'low', label: 'Niedrig', color: 'bg-green-500', text: 'text-green-700' },
|
||||||
|
]
|
||||||
|
|
||||||
|
interface StationCardProps {
|
||||||
|
station: StationDashboard
|
||||||
|
expanded: boolean
|
||||||
|
onToggle: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StationCard({ station, expanded, onToggle }: StationCardProps) {
|
||||||
|
const stationType = STATION_TYPES[station.station.station_type]
|
||||||
|
const bgColor = stationType?.bgColor || 'bg-gray-50'
|
||||||
|
const accentColor = stationType?.color || '#6B7280'
|
||||||
|
|
||||||
|
const totalRisk = Object.values(station.risk_summary).reduce((a, b) => a + b, 0)
|
||||||
|
const pctBar = station.completeness_pct
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`w-56 flex-shrink-0 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 overflow-hidden shadow-sm hover:shadow-md transition-shadow`}
|
||||||
|
>
|
||||||
|
{/* Color accent bar */}
|
||||||
|
<div className="h-1.5" style={{ backgroundColor: accentColor }} />
|
||||||
|
|
||||||
|
{/* Collapsed content */}
|
||||||
|
<div className="p-4">
|
||||||
|
{/* Icon + name */}
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<div
|
||||||
|
className={`w-10 h-10 ${bgColor} dark:bg-opacity-20 rounded-lg flex items-center justify-center flex-shrink-0`}
|
||||||
|
style={{ color: accentColor }}
|
||||||
|
>
|
||||||
|
<StationIcon type={station.station.station_type} size={22} />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-sm font-semibold text-gray-900 dark:text-white truncate">
|
||||||
|
{station.station.station_label}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
|
||||||
|
{station.project_name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hazard count */}
|
||||||
|
<div className="text-xs text-gray-600 dark:text-gray-400 mb-2">
|
||||||
|
{station.hazard_count} Gefaehrdungen
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Completeness bar */}
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<div className="flex-1 bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className="h-2 rounded-full transition-all"
|
||||||
|
style={{
|
||||||
|
width: `${pctBar}%`,
|
||||||
|
backgroundColor: accentColor,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-medium text-gray-600 dark:text-gray-400 w-8 text-right">
|
||||||
|
{pctBar}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SIL / PL */}
|
||||||
|
{(station.sil_max || station.pl_max) && (
|
||||||
|
<div className="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||||
|
{station.sil_max && <span>SIL {station.sil_max}</span>}
|
||||||
|
{station.sil_max && station.pl_max && <span>|</span>}
|
||||||
|
{station.pl_max && <span>PL {station.pl_max}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Toggle button */}
|
||||||
|
<button
|
||||||
|
onClick={onToggle}
|
||||||
|
className="w-full text-left text-xs text-purple-600 dark:text-purple-400 hover:text-purple-700 dark:hover:text-purple-300 font-medium flex items-center gap-1"
|
||||||
|
>
|
||||||
|
{expanded ? 'Weniger anzeigen' : 'Details anzeigen'}
|
||||||
|
<svg
|
||||||
|
className={`w-3.5 h-3.5 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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expanded content */}
|
||||||
|
{expanded && (
|
||||||
|
<div className="px-4 pb-4 border-t border-gray-100 dark:border-gray-700 pt-3 space-y-3">
|
||||||
|
{/* Risk breakdown */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{RISK_LEVELS.map((level) => {
|
||||||
|
const count = station.risk_summary[level.key] || 0
|
||||||
|
const pct = totalRisk > 0 ? Math.round((count / totalRisk) * 100) : 0
|
||||||
|
return (
|
||||||
|
<div key={level.key} className="flex items-center gap-2">
|
||||||
|
<span className={`text-[10px] font-medium w-12 ${level.text}`}>{level.label}</span>
|
||||||
|
<div className="flex-1 bg-gray-100 dark:bg-gray-700 rounded-full h-2.5 overflow-hidden">
|
||||||
|
<div className={`${level.color} h-2.5 rounded-full`} style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-bold text-gray-700 dark:text-gray-300 w-6 text-right">{count}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mitigation count */}
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">Massnahmen</span>
|
||||||
|
<span className="font-semibold text-gray-700 dark:text-gray-300">{station.mitigation_count}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status badge */}
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">Status</span>
|
||||||
|
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${STATUS_COLORS[station.status] || STATUS_COLORS.draft}`}>
|
||||||
|
{STATUS_LABELS[station.status] || station.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Link to project */}
|
||||||
|
<Link
|
||||||
|
href={`/sdk/iace/${station.station.project_id}`}
|
||||||
|
className="block text-center text-xs font-medium text-purple-600 dark:text-purple-400 hover:text-purple-700 bg-purple-50 dark:bg-purple-900/20 rounded-lg py-2 transition-colors"
|
||||||
|
>
|
||||||
|
Zum Projekt →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
interface StationIconProps {
|
||||||
|
type: string
|
||||||
|
size?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StationIcon({ type, size = 24 }: StationIconProps) {
|
||||||
|
const s = size
|
||||||
|
const sw = 1.5
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'press':
|
||||||
|
return (
|
||||||
|
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={sw} strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
{/* Ram pressing down */}
|
||||||
|
<rect x="7" y="2" width="10" height="4" rx="1" />
|
||||||
|
<line x1="12" y1="6" x2="12" y2="12" />
|
||||||
|
<path d="M6 12h12v3H6z" />
|
||||||
|
<line x1="12" y1="12" x2="12" y2="10" strokeWidth={2.5} />
|
||||||
|
{/* Base block */}
|
||||||
|
<rect x="5" y="18" width="14" height="4" rx="1" />
|
||||||
|
{/* Workpiece */}
|
||||||
|
<rect x="9" y="15" width="6" height="3" rx="0.5" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'robot':
|
||||||
|
case 'cobot':
|
||||||
|
case 'collaborative_robot':
|
||||||
|
return (
|
||||||
|
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={sw} strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
{/* Base */}
|
||||||
|
<rect x="8" y="19" width="8" height="3" rx="1" />
|
||||||
|
{/* Lower arm */}
|
||||||
|
<line x1="12" y1="19" x2="8" y2="13" />
|
||||||
|
{/* Joint */}
|
||||||
|
<circle cx="8" cy="13" r="1.5" />
|
||||||
|
{/* Upper arm */}
|
||||||
|
<line x1="8" y1="13" x2="15" y2="7" />
|
||||||
|
{/* Wrist joint */}
|
||||||
|
<circle cx="15" cy="7" r="1.5" />
|
||||||
|
{/* Gripper */}
|
||||||
|
<line x1="15" y1="7" x2="18" y2="4" />
|
||||||
|
<line x1="18" y1="4" x2="19" y2="3" />
|
||||||
|
<line x1="18" y1="4" x2="19" y2="5" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'conveyor':
|
||||||
|
return (
|
||||||
|
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={sw} strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
{/* Belt top */}
|
||||||
|
<line x1="3" y1="14" x2="21" y2="14" />
|
||||||
|
{/* Belt bottom */}
|
||||||
|
<line x1="3" y1="18" x2="21" y2="18" />
|
||||||
|
{/* Left roller */}
|
||||||
|
<circle cx="5" cy="16" r="2" />
|
||||||
|
{/* Right roller */}
|
||||||
|
<circle cx="19" cy="16" r="2" />
|
||||||
|
{/* Flow arrows */}
|
||||||
|
<path d="M8 10l3-2 3 2" />
|
||||||
|
<path d="M11 8v-2" />
|
||||||
|
{/* Package on belt */}
|
||||||
|
<rect x="9" y="10" width="6" height="4" rx="0.5" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'assembly':
|
||||||
|
return (
|
||||||
|
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={sw} strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
{/* Gear */}
|
||||||
|
<circle cx="12" cy="12" r="4" />
|
||||||
|
<circle cx="12" cy="12" r="1.5" />
|
||||||
|
{/* Gear teeth */}
|
||||||
|
<line x1="12" y1="3" x2="12" y2="6" />
|
||||||
|
<line x1="12" y1="18" x2="12" y2="21" />
|
||||||
|
<line x1="3" y1="12" x2="6" y2="12" />
|
||||||
|
<line x1="18" y1="12" x2="21" y2="12" />
|
||||||
|
<line x1="5.6" y1="5.6" x2="7.8" y2="7.8" />
|
||||||
|
<line x1="16.2" y1="16.2" x2="18.4" y2="18.4" />
|
||||||
|
<line x1="5.6" y1="18.4" x2="7.8" y2="16.2" />
|
||||||
|
<line x1="16.2" y1="7.8" x2="18.4" y2="5.6" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'milling':
|
||||||
|
return (
|
||||||
|
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={sw} strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
{/* Spindle */}
|
||||||
|
<rect x="10" y="2" width="4" height="6" rx="1" />
|
||||||
|
{/* Cutter head */}
|
||||||
|
<circle cx="12" cy="11" r="3" />
|
||||||
|
{/* Rotation arc */}
|
||||||
|
<path d="M7 11a5 5 0 0 1 2.5-4.3" strokeDasharray="2 2" />
|
||||||
|
<path d="M17 11a5 5 0 0 0-2.5-4.3" strokeDasharray="2 2" />
|
||||||
|
{/* Workpiece / table */}
|
||||||
|
<rect x="4" y="17" width="16" height="3" rx="1" />
|
||||||
|
<rect x="8" y="14" width="8" height="3" rx="0.5" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'turning':
|
||||||
|
return (
|
||||||
|
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={sw} strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
{/* Chuck / rotating workpiece */}
|
||||||
|
<circle cx="9" cy="12" r="5" />
|
||||||
|
<circle cx="9" cy="12" r="2" />
|
||||||
|
{/* Tool holder */}
|
||||||
|
<line x1="16" y1="12" x2="14" y2="12" />
|
||||||
|
<path d="M16 9v6l4-1v-4z" />
|
||||||
|
{/* Rotation arrow */}
|
||||||
|
<path d="M5 5a8 8 0 0 1 4 1" />
|
||||||
|
<path d="M5 5l1.5 1.5L4.5 7" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'welding':
|
||||||
|
return (
|
||||||
|
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={sw} strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
{/* Torch body */}
|
||||||
|
<line x1="6" y1="4" x2="12" y2="14" />
|
||||||
|
<path d="M4 3h4l-2 3z" />
|
||||||
|
{/* Weld point */}
|
||||||
|
<circle cx="12" cy="16" r="1" fill="currentColor" />
|
||||||
|
{/* Sparks */}
|
||||||
|
<line x1="12" y1="16" x2="15" y2="13" />
|
||||||
|
<line x1="12" y1="16" x2="16" y2="15" />
|
||||||
|
<line x1="12" y1="16" x2="14" y2="19" />
|
||||||
|
<line x1="12" y1="16" x2="9" y2="19" />
|
||||||
|
{/* Workpiece */}
|
||||||
|
<rect x="3" y="19" width="18" height="3" rx="1" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'inspection':
|
||||||
|
return (
|
||||||
|
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={sw} strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
{/* Magnifying glass */}
|
||||||
|
<circle cx="10" cy="10" r="6" />
|
||||||
|
<line x1="14.5" y1="14.5" x2="20" y2="20" strokeWidth={2} />
|
||||||
|
{/* Checkmark inside */}
|
||||||
|
<path d="M7.5 10l2 2 3.5-4" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'packaging':
|
||||||
|
return (
|
||||||
|
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={sw} strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
{/* Box */}
|
||||||
|
<path d="M3 8l9-5 9 5v10l-9 5-9-5z" />
|
||||||
|
<line x1="12" y1="3" x2="12" y2="23" />
|
||||||
|
<line x1="3" y1="8" x2="12" y2="13" />
|
||||||
|
<line x1="21" y1="8" x2="12" y2="13" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'motor':
|
||||||
|
case 'electric_motor':
|
||||||
|
return (
|
||||||
|
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={sw} strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
{/* Motor body circle */}
|
||||||
|
<circle cx="12" cy="12" r="8" />
|
||||||
|
{/* Lightning bolt */}
|
||||||
|
<path d="M13 6l-3 6h4l-3 6" strokeWidth={2} />
|
||||||
|
{/* Shaft */}
|
||||||
|
<line x1="20" y1="12" x2="23" y2="12" strokeWidth={2} />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'rotary_transfer':
|
||||||
|
case 'rotary_transfer_machine':
|
||||||
|
return (
|
||||||
|
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={sw} strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
{/* Circular path */}
|
||||||
|
<circle cx="12" cy="12" r="8" strokeDasharray="4 2" />
|
||||||
|
{/* Center */}
|
||||||
|
<circle cx="12" cy="12" r="2" />
|
||||||
|
{/* Station dots around circle */}
|
||||||
|
<circle cx="12" cy="4" r="1.5" fill="currentColor" />
|
||||||
|
<circle cx="19" cy="8" r="1.5" fill="currentColor" />
|
||||||
|
<circle cx="19" cy="16" r="1.5" fill="currentColor" />
|
||||||
|
<circle cx="12" cy="20" r="1.5" fill="currentColor" />
|
||||||
|
<circle cx="5" cy="16" r="1.5" fill="currentColor" />
|
||||||
|
<circle cx="5" cy="8" r="1.5" fill="currentColor" />
|
||||||
|
{/* Rotation arrow */}
|
||||||
|
<path d="M16 3.5l-1 2h2z" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={sw} strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<rect x="4" y="4" width="16" height="16" rx="2" />
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
import type { TransferInfo } from '../../_types'
|
||||||
|
|
||||||
|
interface TransferLineProps {
|
||||||
|
transfer: TransferInfo
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TransferLine({ transfer, color }: TransferLineProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center w-20 flex-shrink-0 py-4">
|
||||||
|
<style>{`
|
||||||
|
@keyframes iace-running-dots {
|
||||||
|
0% { stroke-dashoffset: 12; }
|
||||||
|
100% { stroke-dashoffset: 0; }
|
||||||
|
}
|
||||||
|
.iace-transfer-dots {
|
||||||
|
animation: iace-running-dots 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
<svg width="80" height="32" viewBox="0 0 80 32" className="overflow-visible">
|
||||||
|
{/* Background line */}
|
||||||
|
<line
|
||||||
|
x1="0"
|
||||||
|
y1="16"
|
||||||
|
x2="80"
|
||||||
|
y2="16"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeOpacity="0.3"
|
||||||
|
/>
|
||||||
|
{/* Animated running dots */}
|
||||||
|
<line
|
||||||
|
x1="0"
|
||||||
|
y1="16"
|
||||||
|
x2="80"
|
||||||
|
y2="16"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth="2.5"
|
||||||
|
strokeDasharray="4 8"
|
||||||
|
className="iace-transfer-dots"
|
||||||
|
/>
|
||||||
|
{/* Arrowhead */}
|
||||||
|
<polygon
|
||||||
|
points="74,11 80,16 74,21"
|
||||||
|
fill={color}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{/* Label */}
|
||||||
|
{transfer.label && (
|
||||||
|
<span className="text-[10px] text-gray-500 dark:text-gray-400 mt-1 text-center leading-tight max-w-[80px] truncate">
|
||||||
|
{transfer.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback } from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { useParams } from 'next/navigation'
|
||||||
|
import { AggregatePanel } from './_components/AggregatePanel'
|
||||||
|
import { StationCard } from './_components/StationCard'
|
||||||
|
import { TransferLine } from './_components/TransferLine'
|
||||||
|
import type { LineDashboard, StationDashboard, TransferInfo } from '../_types'
|
||||||
|
import { TRANSFER_COLORS } from '../_types'
|
||||||
|
|
||||||
|
/** Number of stations per visual row before wrapping */
|
||||||
|
const STATIONS_PER_ROW = 4
|
||||||
|
|
||||||
|
export default function LineDashboardPage() {
|
||||||
|
const params = useParams()
|
||||||
|
const lineId = params.lineId as string
|
||||||
|
const [dashboard, setDashboard] = useState<LineDashboard | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [expandedStation, setExpandedStation] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const fetchDashboard = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/sdk/v1/iace/production-lines/${lineId}/dashboard`)
|
||||||
|
if (!res.ok) {
|
||||||
|
setError('Produktionslinie konnte nicht geladen werden')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const json = await res.json()
|
||||||
|
setDashboard(json)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch line dashboard:', err)
|
||||||
|
setError('Verbindung zum Backend fehlgeschlagen')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [lineId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchDashboard()
|
||||||
|
}, [fetchDashboard])
|
||||||
|
|
||||||
|
function handleToggle(stationId: string) {
|
||||||
|
setExpandedStation((prev) => (prev === stationId ? null : stationId))
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !dashboard) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12 space-y-3">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
{error || 'Produktionslinie nicht gefunden'}
|
||||||
|
</h2>
|
||||||
|
<Link href="/sdk/iace/lines" className="text-purple-600 hover:text-purple-700">
|
||||||
|
Zurueck zur Uebersicht
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedStations = [...dashboard.stations].sort(
|
||||||
|
(a, b) => a.station.sort_order - b.station.sort_order
|
||||||
|
)
|
||||||
|
|
||||||
|
// Build rows of stations for display
|
||||||
|
const rows = buildStationRows(sortedStations, STATIONS_PER_ROW)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 max-w-7xl mx-auto">
|
||||||
|
{/* Back link */}
|
||||||
|
<Link
|
||||||
|
href="/sdk/iace/lines"
|
||||||
|
className="inline-flex items-center gap-1 text-sm text-purple-600 hover:text-purple-700 dark:text-purple-400 dark:hover:text-purple-300 font-medium"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
Alle Produktionslinien
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Aggregate panel */}
|
||||||
|
<AggregatePanel dashboard={dashboard} />
|
||||||
|
|
||||||
|
{/* Station flow */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||||
|
<h2 className="text-sm font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
|
Stationsuebersicht
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="space-y-6 overflow-x-auto">
|
||||||
|
{rows.map((row, rowIndex) => (
|
||||||
|
<StationRow
|
||||||
|
key={rowIndex}
|
||||||
|
stations={row}
|
||||||
|
transfers={dashboard.transfers}
|
||||||
|
expandedStation={expandedStation}
|
||||||
|
onToggle={handleToggle}
|
||||||
|
reversed={rowIndex % 2 === 1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Split sorted stations into rows of N for layout */
|
||||||
|
function buildStationRows(
|
||||||
|
stations: StationDashboard[],
|
||||||
|
perRow: number
|
||||||
|
): StationDashboard[][] {
|
||||||
|
const rows: StationDashboard[][] = []
|
||||||
|
for (let i = 0; i < stations.length; i += perRow) {
|
||||||
|
rows.push(stations.slice(i, i + perRow))
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Find the transfer between two adjacent station sort orders */
|
||||||
|
function findTransfer(
|
||||||
|
transfers: TransferInfo[],
|
||||||
|
fromOrder: number,
|
||||||
|
toOrder: number
|
||||||
|
): TransferInfo | null {
|
||||||
|
return (
|
||||||
|
transfers.find(
|
||||||
|
(t) => t.from_station === fromOrder && t.to_station === toOrder
|
||||||
|
) || null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Default transfer for stations without an explicit transfer entry */
|
||||||
|
function defaultTransfer(from: number, to: number): TransferInfo {
|
||||||
|
return { from_station: from, to_station: to, type: 'conveyor', label: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StationRowProps {
|
||||||
|
stations: StationDashboard[]
|
||||||
|
transfers: TransferInfo[]
|
||||||
|
expandedStation: string | null
|
||||||
|
onToggle: (id: string) => void
|
||||||
|
reversed: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function StationRow({ stations, transfers, expandedStation, onToggle, reversed }: StationRowProps) {
|
||||||
|
// Reverse even rows for a serpentine layout
|
||||||
|
const ordered = reversed ? [...stations].reverse() : stations
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-0 overflow-x-auto pb-2">
|
||||||
|
{ordered.map((station, idx) => {
|
||||||
|
const nextStation = ordered[idx + 1]
|
||||||
|
const transfer = nextStation
|
||||||
|
? findTransfer(
|
||||||
|
transfers,
|
||||||
|
station.station.sort_order,
|
||||||
|
nextStation.station.sort_order
|
||||||
|
) ||
|
||||||
|
findTransfer(
|
||||||
|
transfers,
|
||||||
|
nextStation.station.sort_order,
|
||||||
|
station.station.sort_order
|
||||||
|
) ||
|
||||||
|
defaultTransfer(station.station.sort_order, nextStation.station.sort_order)
|
||||||
|
: null
|
||||||
|
|
||||||
|
const transferColor = transfer
|
||||||
|
? TRANSFER_COLORS[transfer.type] || TRANSFER_COLORS.conveyor
|
||||||
|
: '#22C55E'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment key={station.station.id}>
|
||||||
|
<StationCard
|
||||||
|
station={station}
|
||||||
|
expanded={expandedStation === station.station.id}
|
||||||
|
onToggle={() => onToggle(station.station.id)}
|
||||||
|
/>
|
||||||
|
{transfer && (
|
||||||
|
<TransferLine transfer={transfer} color={transferColor} />
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
export interface ProductionLine {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StationDashboard {
|
||||||
|
station: {
|
||||||
|
id: string
|
||||||
|
line_id: string
|
||||||
|
project_id: string
|
||||||
|
station_type: string
|
||||||
|
station_label: string
|
||||||
|
sort_order: number
|
||||||
|
}
|
||||||
|
project_name: string
|
||||||
|
machine_type: string
|
||||||
|
status: string
|
||||||
|
risk_summary: Record<string, number>
|
||||||
|
hazard_count: number
|
||||||
|
mitigation_count: number
|
||||||
|
completeness_pct: number
|
||||||
|
sil_max: string
|
||||||
|
pl_max: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TransferInfo {
|
||||||
|
from_station: number
|
||||||
|
to_station: number
|
||||||
|
type: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LineDashboard {
|
||||||
|
line: ProductionLine
|
||||||
|
stations: StationDashboard[]
|
||||||
|
transfers: TransferInfo[]
|
||||||
|
aggregate: Record<string, number>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const STATION_TYPES: Record<string, { label: string; color: string; bgColor: string }> = {
|
||||||
|
press: { label: 'Presse', color: '#EF4444', bgColor: 'bg-red-50' },
|
||||||
|
robot: { label: 'Roboter', color: '#3B82F6', bgColor: 'bg-blue-50' },
|
||||||
|
cobot: { label: 'Cobot', color: '#3B82F6', bgColor: 'bg-blue-50' },
|
||||||
|
collaborative_robot: { label: 'Cobot', color: '#3B82F6', bgColor: 'bg-blue-50' },
|
||||||
|
conveyor: { label: 'Foerderer', color: '#22C55E', bgColor: 'bg-green-50' },
|
||||||
|
assembly: { label: 'Montage', color: '#F97316', bgColor: 'bg-orange-50' },
|
||||||
|
milling: { label: 'Fraese', color: '#8B5CF6', bgColor: 'bg-purple-50' },
|
||||||
|
turning: { label: 'Drehmaschine', color: '#1D4ED8', bgColor: 'bg-blue-50' },
|
||||||
|
welding: { label: 'Schweissen', color: '#EAB308', bgColor: 'bg-yellow-50' },
|
||||||
|
inspection: { label: 'Pruefung', color: '#06B6D4', bgColor: 'bg-cyan-50' },
|
||||||
|
packaging: { label: 'Verpackung', color: '#92400E', bgColor: 'bg-amber-50' },
|
||||||
|
motor: { label: 'Motor', color: '#6B7280', bgColor: 'bg-gray-50' },
|
||||||
|
electric_motor: { label: 'Elektromotor', color: '#6B7280', bgColor: 'bg-gray-50' },
|
||||||
|
rotary_transfer: { label: 'Rundtakt', color: '#7C3AED', bgColor: 'bg-violet-50' },
|
||||||
|
rotary_transfer_machine: { label: 'Rundtakt', color: '#7C3AED', bgColor: 'bg-violet-50' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TRANSFER_COLORS: Record<string, string> = {
|
||||||
|
conveyor: '#22C55E',
|
||||||
|
robot: '#3B82F6',
|
||||||
|
manual: '#EAB308',
|
||||||
|
crane: '#F97316',
|
||||||
|
agv: '#8B5CF6',
|
||||||
|
}
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
interface ProductionLineItem {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
station_count: number
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProjectItem {
|
||||||
|
id: string
|
||||||
|
machine_name: string
|
||||||
|
machine_type: string
|
||||||
|
status: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATION_TYPES = [
|
||||||
|
{ value: 'press', label: 'Presse' },
|
||||||
|
{ value: 'cobot', label: 'Cobot/Roboter' },
|
||||||
|
{ value: 'motor', label: 'Motor/Antrieb' },
|
||||||
|
{ value: 'rotary_transfer', label: 'Rundtaktanlage' },
|
||||||
|
{ value: 'conveyor', label: 'Foerderer' },
|
||||||
|
{ value: 'assembly', label: 'Montage' },
|
||||||
|
{ value: 'milling', label: 'Fraese' },
|
||||||
|
{ value: 'turning', label: 'Drehmaschine' },
|
||||||
|
{ value: 'welding', label: 'Schweissen' },
|
||||||
|
{ value: 'inspection', label: 'Pruefung' },
|
||||||
|
{ value: 'packaging', label: 'Verpackung' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function ProductionLinesListPage() {
|
||||||
|
const [lines, setLines] = useState<ProductionLineItem[]>([])
|
||||||
|
const [projects, setProjects] = useState<ProjectItem[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [showCreate, setShowCreate] = useState(false)
|
||||||
|
const [creating, setCreating] = useState(false)
|
||||||
|
const [lineName, setLineName] = useState('')
|
||||||
|
const [lineDesc, setLineDesc] = useState('')
|
||||||
|
const [selectedStations, setSelectedStations] = useState<Array<{ projectId: string; stationType: string }>>([])
|
||||||
|
|
||||||
|
useEffect(() => { fetchLines(); fetchProjects() }, [])
|
||||||
|
|
||||||
|
async function fetchLines() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/sdk/v1/iace/production-lines')
|
||||||
|
if (res.ok) {
|
||||||
|
const json = await res.json()
|
||||||
|
setLines(json.lines || [])
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
finally { setLoading(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchProjects() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/sdk/v1/iace/projects')
|
||||||
|
if (res.ok) {
|
||||||
|
const json = await res.json()
|
||||||
|
setProjects((json.projects || []).map((p: Record<string, unknown>) => ({
|
||||||
|
id: p.id, machine_name: p.machine_name, machine_type: p.machine_type, status: p.status,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function addStation() {
|
||||||
|
setSelectedStations((prev) => [...prev, { projectId: '', stationType: 'assembly' }])
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeStation(idx: number) {
|
||||||
|
setSelectedStations((prev) => prev.filter((_, i) => i !== idx))
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStation(idx: number, field: 'projectId' | 'stationType', value: string) {
|
||||||
|
setSelectedStations((prev) => prev.map((s, i) => i === idx ? { ...s, [field]: value } : s))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreate() {
|
||||||
|
if (!lineName.trim()) return
|
||||||
|
setCreating(true)
|
||||||
|
try {
|
||||||
|
// 1. Create the line
|
||||||
|
const lineRes = await fetch('/api/sdk/v1/iace/production-lines', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name: lineName.trim(), description: lineDesc.trim() }),
|
||||||
|
})
|
||||||
|
if (!lineRes.ok) return
|
||||||
|
const lineJson = await lineRes.json()
|
||||||
|
const lineId = lineJson.line?.id || lineJson.id
|
||||||
|
|
||||||
|
// 2. Add stations
|
||||||
|
for (let i = 0; i < selectedStations.length; i++) {
|
||||||
|
const s = selectedStations[i]
|
||||||
|
if (!s.projectId) continue
|
||||||
|
const proj = projects.find((p) => p.id === s.projectId)
|
||||||
|
await fetch(`/api/sdk/v1/iace/production-lines/${lineId}/stations`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
project_id: s.projectId,
|
||||||
|
station_type: s.stationType,
|
||||||
|
station_label: proj?.machine_name || '',
|
||||||
|
sort_order: i + 1,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowCreate(false)
|
||||||
|
setLineName('')
|
||||||
|
setLineDesc('')
|
||||||
|
setSelectedStations([])
|
||||||
|
await fetchLines()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to create line:', err)
|
||||||
|
} finally {
|
||||||
|
setCreating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 max-w-6xl mx-auto">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<Link href="/sdk/iace" className="text-xs text-purple-600 hover:text-purple-700 font-medium flex items-center gap-1 mb-1">
|
||||||
|
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
IACE
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Produktionslinien</h1>
|
||||||
|
<p className="mt-1 text-sm text-gray-500">Verkettete Fertigungsstrassen mit aggregierter Risikoansicht</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setShowCreate(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 Produktionslinie
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create form */}
|
||||||
|
{showCreate && (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl border border-purple-200 p-6 space-y-4">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Neue Produktionslinie erstellen</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={lineName} onChange={(e) => setLineName(e.target.value)}
|
||||||
|
placeholder="z.B. Fertigungsstrasse Halle 3"
|
||||||
|
className="w-full px-4 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" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Beschreibung</label>
|
||||||
|
<input type="text" value={lineDesc} onChange={(e) => setLineDesc(e.target.value)}
|
||||||
|
placeholder="Optionale Beschreibung"
|
||||||
|
className="w-full px-4 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" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stations */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">Stationen (Projekte zuordnen)</label>
|
||||||
|
<button onClick={addStation} className="text-xs text-purple-600 hover:text-purple-700 font-medium">+ Station hinzufuegen</button>
|
||||||
|
</div>
|
||||||
|
{selectedStations.length === 0 && (
|
||||||
|
<p className="text-xs text-gray-400 italic">Noch keine Stationen. Klicken Sie "+ Station hinzufuegen" um Projekte zuzuordnen.</p>
|
||||||
|
)}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{selectedStations.map((s, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2 p-2 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||||
|
<span className="text-xs font-bold text-gray-400 w-6">{i + 1}.</span>
|
||||||
|
<select value={s.projectId} onChange={(e) => updateStation(i, 'projectId', e.target.value)}
|
||||||
|
className="flex-1 px-2 py-1.5 text-sm border border-gray-300 rounded-lg dark:bg-gray-600 dark:border-gray-500 dark:text-white">
|
||||||
|
<option value="">-- Projekt waehlen --</option>
|
||||||
|
{projects.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>{p.machine_name} ({p.machine_type})</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select value={s.stationType} onChange={(e) => updateStation(i, 'stationType', e.target.value)}
|
||||||
|
className="w-40 px-2 py-1.5 text-sm border border-gray-300 rounded-lg dark:bg-gray-600 dark:border-gray-500 dark:text-white">
|
||||||
|
{STATION_TYPES.map((t) => (
|
||||||
|
<option key={t.value} value={t.value}>{t.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button onClick={() => removeStation(i)} className="p-1 text-red-400 hover:text-red-600">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
|
<button onClick={handleCreate} disabled={!lineName.trim() || creating}
|
||||||
|
className={`px-6 py-2 rounded-lg font-medium transition-colors ${lineName.trim() && !creating ? 'bg-purple-600 text-white hover:bg-purple-700' : 'bg-gray-200 text-gray-400 cursor-not-allowed'}`}>
|
||||||
|
{creating ? 'Wird erstellt...' : 'Produktionslinie erstellen'}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => { setShowCreate(false); setSelectedStations([]) }}
|
||||||
|
className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Lines list */}
|
||||||
|
{lines.length > 0 && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{lines.map((line) => (
|
||||||
|
<Link key={line.id} href={`/sdk/iace/lines/${line.id}`}
|
||||||
|
className="block bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 hover:shadow-md hover:border-purple-300 transition-all">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-1">{line.name}</h3>
|
||||||
|
{line.description && <p className="text-sm text-gray-500 mb-3 line-clamp-2">{line.description}</p>}
|
||||||
|
<div className="flex items-center gap-4 text-xs text-gray-500">
|
||||||
|
<span>{line.station_count || 0} Stationen</span>
|
||||||
|
<span>Aktualisiert: {new Date(line.updated_at || line.created_at).toLocaleDateString('de-DE')}</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{lines.length === 0 && !showCreate && (
|
||||||
|
<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 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="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>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Noch keine Produktionslinien</h3>
|
||||||
|
<p className="mt-2 text-gray-500 max-w-lg mx-auto">
|
||||||
|
Produktionslinien verketten mehrere CE-Projekte zu einer Fertigungsstrasse.
|
||||||
|
</p>
|
||||||
|
<button onClick={() => { setShowCreate(true); addStation() }}
|
||||||
|
className="mt-6 px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors font-medium">
|
||||||
|
Erste Produktionslinie erstellen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
import { ProcessFlow } from './_components/ProcessFlow'
|
||||||
|
|
||||||
interface IACEProject {
|
interface IACEProject {
|
||||||
id: string
|
id: string
|
||||||
@@ -10,7 +11,7 @@ interface IACEProject {
|
|||||||
manufacturer: string
|
manufacturer: string
|
||||||
status: string
|
status: string
|
||||||
completeness_pct: number
|
completeness_pct: number
|
||||||
risk_summary: {
|
risk_summary?: {
|
||||||
critical: number
|
critical: number
|
||||||
high: number
|
high: number
|
||||||
medium: number
|
medium: number
|
||||||
@@ -54,34 +55,35 @@ function CompletenessBar({ pct }: { pct: number }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function RiskDots({ summary }: { summary: IACEProject['risk_summary'] }) {
|
function RiskDots({ summary }: { summary?: IACEProject['risk_summary'] }) {
|
||||||
|
const s = summary || { critical: 0, high: 0, medium: 0, low: 0 }
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3 text-xs">
|
<div className="flex items-center gap-3 text-xs">
|
||||||
{summary.critical > 0 && (
|
{s.critical > 0 && (
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<span className="w-2.5 h-2.5 rounded-full bg-red-500" />
|
<span className="w-2.5 h-2.5 rounded-full bg-red-500" />
|
||||||
<span className="text-gray-600">{summary.critical}</span>
|
<span className="text-gray-600">{s.critical}</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{summary.high > 0 && (
|
{s.high > 0 && (
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<span className="w-2.5 h-2.5 rounded-full bg-orange-500" />
|
<span className="w-2.5 h-2.5 rounded-full bg-orange-500" />
|
||||||
<span className="text-gray-600">{summary.high}</span>
|
<span className="text-gray-600">{s.high}</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{summary.medium > 0 && (
|
{s.medium > 0 && (
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<span className="w-2.5 h-2.5 rounded-full bg-yellow-500" />
|
<span className="w-2.5 h-2.5 rounded-full bg-yellow-500" />
|
||||||
<span className="text-gray-600">{summary.medium}</span>
|
<span className="text-gray-600">{s.medium}</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{summary.low > 0 && (
|
{s.low > 0 && (
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<span className="w-2.5 h-2.5 rounded-full bg-green-500" />
|
<span className="w-2.5 h-2.5 rounded-full bg-green-500" />
|
||||||
<span className="text-gray-600">{summary.low}</span>
|
<span className="text-gray-600">{s.low}</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{summary.critical === 0 && summary.high === 0 && summary.medium === 0 && summary.low === 0 && (
|
{s.critical === 0 && s.high === 0 && s.medium === 0 && s.low === 0 && (
|
||||||
<span className="text-gray-400">Keine Risiken</span>
|
<span className="text-gray-400">Keine Risiken</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -142,7 +144,13 @@ export default function IACEDashboardPage() {
|
|||||||
const res = await fetch('/api/sdk/v1/iace/projects')
|
const res = await fetch('/api/sdk/v1/iace/projects')
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const json = await res.json()
|
const json = await res.json()
|
||||||
setProjects(json.projects || json || [])
|
const raw = json.projects || json || []
|
||||||
|
// Map API fields to frontend expectations
|
||||||
|
setProjects(raw.map((p: Record<string, unknown>) => ({
|
||||||
|
...p,
|
||||||
|
completeness_pct: p.completeness_pct ?? p.completeness_score ?? 0,
|
||||||
|
risk_summary: p.risk_summary || { critical: 0, high: 0, medium: 0, low: 0 },
|
||||||
|
})))
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch IACE projects:', err)
|
console.error('Failed to fetch IACE projects:', err)
|
||||||
@@ -219,6 +227,36 @@ export default function IACEDashboardPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Production Lines Quick Access */}
|
||||||
|
<Link
|
||||||
|
href="/sdk/iace/lines"
|
||||||
|
className="block bg-gradient-to-r from-purple-50 to-indigo-50 dark:from-purple-900/20 dark:to-indigo-900/20 rounded-xl border border-purple-200 dark:border-purple-800 p-6 hover:shadow-md hover:border-purple-300 transition-all group"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-12 h-12 bg-purple-100 dark:bg-purple-900/40 rounded-xl flex items-center justify-center flex-shrink-0">
|
||||||
|
<svg className="w-6 h-6 text-purple-600" 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>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
Produktionslinien
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Verkettete Fertigungsstrassen mit aggregierter Risikoansicht und animiertem Stationsfluss
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<svg className="w-5 h-5 text-purple-400 group-hover:text-purple-600 transition-colors flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Process Flow */}
|
||||||
|
<ProcessFlow />
|
||||||
|
|
||||||
{/* Create Form */}
|
{/* Create Form */}
|
||||||
{showCreateForm && (
|
{showCreateForm && (
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ import { usePathname, useSearchParams } from 'next/navigation'
|
|||||||
import { SDKProvider } from '@/lib/sdk'
|
import { SDKProvider } from '@/lib/sdk'
|
||||||
import { SDKSidebar } from '@/components/sdk/Sidebar/SDKSidebar'
|
import { SDKSidebar } from '@/components/sdk/Sidebar/SDKSidebar'
|
||||||
import { CommandBar } from '@/components/sdk/CommandBar'
|
import { CommandBar } from '@/components/sdk/CommandBar'
|
||||||
import { SDKPipelineSidebar } from '@/components/sdk/SDKPipelineSidebar'
|
// SDKPipelineSidebar removed — replaced by per-module FAB navigators
|
||||||
import { ComplianceAdvisorWidget } from '@/components/sdk/ComplianceAdvisorWidget'
|
import { ComplianceAdvisorWidget } from '@/components/sdk/ComplianceAdvisorWidget'
|
||||||
|
import { CookieBannerOverlay, CookieBannerFAB } from '@/components/sdk/CookieBannerOverlay'
|
||||||
import { useSDK } from '@/lib/sdk'
|
import { useSDK } from '@/lib/sdk'
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -207,11 +208,14 @@ function SDKInnerLayout({ children }: { children: React.ReactNode }) {
|
|||||||
{/* Command Bar Modal */}
|
{/* Command Bar Modal */}
|
||||||
{isCommandBarOpen && <CommandBar onClose={() => setCommandBarOpen(false)} />}
|
{isCommandBarOpen && <CommandBar onClose={() => setCommandBarOpen(false)} />}
|
||||||
|
|
||||||
{/* Pipeline Sidebar (FAB on mobile/tablet, fixed on desktop xl+) */}
|
{/* Module-specific FAB navigators are rendered by each module's layout */}
|
||||||
{projectId && <SDKPipelineSidebar />}
|
|
||||||
|
|
||||||
{/* Compliance Advisor Widget */}
|
{/* Compliance Advisor Widget — immer sichtbar, auch ohne Projekt */}
|
||||||
{projectId && <ComplianceAdvisorWidget currentStep={currentStep} />}
|
<ComplianceAdvisorWidget currentStep={currentStep} />
|
||||||
|
|
||||||
|
{/* Cookie Banner — opens on first visit, reopenable via FAB */}
|
||||||
|
<CookieBannerOverlay />
|
||||||
|
<CookieBannerFAB />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import React from 'react'
|
|||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useSDK, SDK_PACKAGES, getStepsForPackage } from '@/lib/sdk'
|
import { useSDK, SDK_PACKAGES, getStepsForPackage } from '@/lib/sdk'
|
||||||
import { ProjectSelector } from '@/components/sdk/ProjectSelector/ProjectSelector'
|
import { ProjectSelector } from '@/components/sdk/ProjectSelector/ProjectSelector'
|
||||||
|
import { RegulatoryNewsFeed } from '@/components/sdk/regulatory-news/RegulatoryNewsFeed'
|
||||||
import type { SDKPackageId } from '@/lib/sdk/types'
|
import type { SDKPackageId } from '@/lib/sdk/types'
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -331,6 +332,9 @@ export default function SDKDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Regulatory News */}
|
||||||
|
<RegulatoryNewsFeed businessModel={state.companyProfile?.businessModel as string} />
|
||||||
|
|
||||||
{/* 5 Packages */}
|
{/* 5 Packages */}
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Compliance-Pakete</h2>
|
<h2 className="text-lg font-semibold text-gray-900 mb-4">Compliance-Pakete</h2>
|
||||||
|
|||||||
@@ -0,0 +1,496 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
|
||||||
|
interface PaymentControl {
|
||||||
|
control_id: string
|
||||||
|
domain: string
|
||||||
|
title: string
|
||||||
|
objective: string
|
||||||
|
check_target: string
|
||||||
|
evidence: string[]
|
||||||
|
automation: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PaymentDomain {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Assessment {
|
||||||
|
id: string
|
||||||
|
project_name: string
|
||||||
|
tender_reference: string
|
||||||
|
customer_name: string
|
||||||
|
system_type: string
|
||||||
|
total_controls: number
|
||||||
|
controls_passed: number
|
||||||
|
controls_failed: number
|
||||||
|
controls_partial: number
|
||||||
|
controls_not_applicable: number
|
||||||
|
controls_not_checked: number
|
||||||
|
compliance_score: number
|
||||||
|
status: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TenderAnalysis {
|
||||||
|
id: string
|
||||||
|
file_name: string
|
||||||
|
file_size: number
|
||||||
|
project_name: string
|
||||||
|
customer_name: string
|
||||||
|
status: string
|
||||||
|
total_requirements: number
|
||||||
|
matched_count: number
|
||||||
|
unmatched_count: number
|
||||||
|
partial_count: number
|
||||||
|
requirements?: Array<{ req_id: string; text: string; obligation_level: string; technical_domain: string; confidence: number }>
|
||||||
|
match_results?: Array<{ req_id: string; req_text: string; verdict: string; matched_controls: Array<{ control_id: string; title: string; relevance: number }>; gap_description?: string }>
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const AUTOMATION_STYLES: Record<string, { bg: string; text: string }> = {
|
||||||
|
high: { bg: 'bg-green-100', text: 'text-green-700' },
|
||||||
|
medium: { bg: 'bg-yellow-100', text: 'text-yellow-700' },
|
||||||
|
partial: { bg: 'bg-orange-100', text: 'text-orange-700' },
|
||||||
|
low: { bg: 'bg-red-100', text: 'text-red-700' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const TARGET_ICONS: Record<string, string> = {
|
||||||
|
code: '💻', system: '🖥️', config: '⚙️', process: '📋',
|
||||||
|
repository: '📦', certificate: '📜',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PaymentCompliancePage() {
|
||||||
|
const [controls, setControls] = useState<PaymentControl[]>([])
|
||||||
|
const [domains, setDomains] = useState<PaymentDomain[]>([])
|
||||||
|
const [assessments, setAssessments] = useState<Assessment[]>([])
|
||||||
|
const [tenderAnalyses, setTenderAnalyses] = useState<TenderAnalysis[]>([])
|
||||||
|
const [selectedTender, setSelectedTender] = useState<TenderAnalysis | null>(null)
|
||||||
|
const [selectedDomain, setSelectedDomain] = useState<string>('all')
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [tab, setTab] = useState<'controls' | 'assessments' | 'tender'>('controls')
|
||||||
|
const [uploading, setUploading] = useState(false)
|
||||||
|
const [processing, setProcessing] = useState(false)
|
||||||
|
const [showNewAssessment, setShowNewAssessment] = useState(false)
|
||||||
|
const [newProject, setNewProject] = useState({ project_name: '', tender_reference: '', customer_name: '', system_type: 'full_stack' })
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
const [ctrlResp, assessResp, tenderResp] = await Promise.all([
|
||||||
|
fetch('/api/sdk/v1/payment-compliance?endpoint=controls'),
|
||||||
|
fetch('/api/sdk/v1/payment-compliance?endpoint=assessments'),
|
||||||
|
fetch('/api/sdk/v1/payment-compliance/tender'),
|
||||||
|
])
|
||||||
|
if (ctrlResp.ok) {
|
||||||
|
const data = await ctrlResp.json()
|
||||||
|
setControls(data.controls || [])
|
||||||
|
setDomains(data.domains || [])
|
||||||
|
}
|
||||||
|
if (assessResp.ok) {
|
||||||
|
const data = await assessResp.json()
|
||||||
|
setAssessments(data.assessments || [])
|
||||||
|
}
|
||||||
|
if (tenderResp.ok) {
|
||||||
|
const data = await tenderResp.json()
|
||||||
|
setTenderAnalyses(data.analyses || [])
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
finally { setLoading(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTenderUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
setUploading(true)
|
||||||
|
try {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
formData.append('project_name', file.name.replace(/\.[^.]+$/, ''))
|
||||||
|
const resp = await fetch('/api/sdk/v1/payment-compliance/tender', { method: 'POST', body: formData })
|
||||||
|
if (resp.ok) {
|
||||||
|
const data = await resp.json()
|
||||||
|
// Auto-start extraction + matching
|
||||||
|
setProcessing(true)
|
||||||
|
const extractResp = await fetch(`/api/sdk/v1/payment-compliance/tender/${data.id}?action=extract`, { method: 'POST' })
|
||||||
|
if (extractResp.ok) {
|
||||||
|
await fetch(`/api/sdk/v1/payment-compliance/tender/${data.id}?action=match`, { method: 'POST' })
|
||||||
|
}
|
||||||
|
// Reload and show result
|
||||||
|
const detailResp = await fetch(`/api/sdk/v1/payment-compliance/tender/${data.id}`)
|
||||||
|
if (detailResp.ok) {
|
||||||
|
const detail = await detailResp.json()
|
||||||
|
setSelectedTender(detail)
|
||||||
|
}
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
} catch {} finally {
|
||||||
|
setUploading(false)
|
||||||
|
setProcessing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleViewTender(id: string) {
|
||||||
|
const resp = await fetch(`/api/sdk/v1/payment-compliance/tender/${id}`)
|
||||||
|
if (resp.ok) {
|
||||||
|
setSelectedTender(await resp.json())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreateAssessment() {
|
||||||
|
const resp = await fetch('/api/sdk/v1/payment-compliance', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(newProject),
|
||||||
|
})
|
||||||
|
if (resp.ok) {
|
||||||
|
setShowNewAssessment(false)
|
||||||
|
setNewProject({ project_name: '', tender_reference: '', customer_name: '', system_type: 'full_stack' })
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredControls = selectedDomain === 'all'
|
||||||
|
? controls
|
||||||
|
: controls.filter(c => c.domain === selectedDomain)
|
||||||
|
|
||||||
|
const domainStats = domains.map(d => ({
|
||||||
|
...d,
|
||||||
|
count: controls.filter(c => c.domain === d.id).length,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-6xl mx-auto p-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Payment Terminal Compliance</h1>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
Technische Pruefbibliothek fuer Zahlungssysteme — {controls.length} Controls in {domains.length} Domaenen
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={() => setTab('controls')}
|
||||||
|
className={`px-4 py-2 rounded-lg text-sm font-medium ${tab === 'controls' ? 'bg-purple-600 text-white' : 'bg-gray-100 text-gray-700'}`}>
|
||||||
|
Controls ({controls.length})
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setTab('assessments')}
|
||||||
|
className={`px-4 py-2 rounded-lg text-sm font-medium ${tab === 'assessments' ? 'bg-purple-600 text-white' : 'bg-gray-100 text-gray-700'}`}>
|
||||||
|
Assessments ({assessments.length})
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setTab('tender')}
|
||||||
|
className={`px-4 py-2 rounded-lg text-sm font-medium ${tab === 'tender' ? 'bg-purple-600 text-white' : 'bg-gray-100 text-gray-700'}`}>
|
||||||
|
Ausschreibung ({tenderAnalyses.length})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Box */}
|
||||||
|
<div className="mb-6 p-4 bg-blue-50 border border-blue-200 rounded-xl text-sm text-blue-800">
|
||||||
|
<div className="font-semibold mb-2">Wie funktioniert Payment Terminal Compliance?</div>
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium mb-1">1. Controls durchsuchen</div>
|
||||||
|
<p className="text-xs text-blue-700">Unsere Bibliothek enthaelt {controls.length} technische Pruefregeln fuer Zahlungssysteme — von Transaktionslogik ueber Kryptographie bis ZVT/OPI-Protokollverhalten. Jeder Control definiert was geprueft wird und welche Evidenz noetig ist.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium mb-1">2. Assessment erstellen</div>
|
||||||
|
<p className="text-xs text-blue-700">Ein Assessment ist eine projektbezogene Pruefung — z.B. fuer eine bestimmte Ausschreibung oder einen Kunden. Sie ordnet jedem Control einen Status zu: bestanden, fehlgeschlagen, teilweise oder nicht anwendbar.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium mb-1">3. Ausschreibung analysieren</div>
|
||||||
|
<p className="text-xs text-blue-700">Laden Sie ein Ausschreibungsdokument hoch. Die KI extrahiert automatisch die Anforderungen und matcht sie gegen unsere Controls. Ergebnis: Welche Anforderungen sind abgedeckt und wo gibt es Luecken.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center py-12 text-gray-500">Lade...</div>
|
||||||
|
) : tab === 'controls' ? (
|
||||||
|
<>
|
||||||
|
{/* Domain Filter */}
|
||||||
|
<div className="grid grid-cols-5 gap-3 mb-6">
|
||||||
|
<button onClick={() => setSelectedDomain('all')}
|
||||||
|
className={`p-3 rounded-xl border text-center ${selectedDomain === 'all' ? 'border-purple-500 bg-purple-50' : 'border-gray-200 hover:border-purple-300'}`}>
|
||||||
|
<div className="text-lg font-bold text-purple-700">{controls.length}</div>
|
||||||
|
<div className="text-xs text-gray-500">Alle</div>
|
||||||
|
</button>
|
||||||
|
{domainStats.map(d => (
|
||||||
|
<button key={d.id} onClick={() => setSelectedDomain(d.id)}
|
||||||
|
className={`p-3 rounded-xl border text-center ${selectedDomain === d.id ? 'border-purple-500 bg-purple-50' : 'border-gray-200 hover:border-purple-300'}`}>
|
||||||
|
<div className="text-lg font-bold text-gray-900">{d.count}</div>
|
||||||
|
<div className="text-xs text-gray-500 truncate">{d.id}</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Domain Description */}
|
||||||
|
{selectedDomain !== 'all' && (
|
||||||
|
<div className="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-lg text-sm text-blue-800">
|
||||||
|
<strong>{domains.find(d => d.id === selectedDomain)?.name}:</strong>{' '}
|
||||||
|
{domains.find(d => d.id === selectedDomain)?.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Controls List */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{filteredControls.map(ctrl => {
|
||||||
|
const autoStyle = AUTOMATION_STYLES[ctrl.automation] || AUTOMATION_STYLES.low
|
||||||
|
return (
|
||||||
|
<div key={ctrl.control_id} className="bg-white rounded-xl border border-gray-200 p-4 hover:border-purple-300 transition-all">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="text-xs font-mono text-purple-600 bg-purple-50 px-2 py-0.5 rounded">{ctrl.control_id}</span>
|
||||||
|
<span className="text-xs text-gray-400">{TARGET_ICONS[ctrl.check_target] || '🔍'} {ctrl.check_target}</span>
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded-full ${autoStyle.bg} ${autoStyle.text}`}>
|
||||||
|
{ctrl.automation}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900">{ctrl.title}</h3>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">{ctrl.objective}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1 mt-2">
|
||||||
|
{ctrl.evidence.map(ev => (
|
||||||
|
<span key={ev} className="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded">{ev}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : tab === 'assessments' ? (
|
||||||
|
<>
|
||||||
|
{/* Assessments Tab */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<button onClick={() => setShowNewAssessment(true)}
|
||||||
|
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700">
|
||||||
|
+ Neues Assessment
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showNewAssessment && (
|
||||||
|
<div className="mb-6 p-6 bg-white rounded-xl border border-purple-200">
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-4">Neues Payment Compliance Assessment</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Projektname *</label>
|
||||||
|
<input value={newProject.project_name} onChange={e => setNewProject(p => ({ ...p, project_name: e.target.value }))}
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500" placeholder="z.B. Ausschreibung Muenchen 2026" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Ausschreibungs-Referenz</label>
|
||||||
|
<input value={newProject.tender_reference} onChange={e => setNewProject(p => ({ ...p, tender_reference: e.target.value }))}
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500" placeholder="z.B. 2026-PAY-001" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Kunde</label>
|
||||||
|
<input value={newProject.customer_name} onChange={e => setNewProject(p => ({ ...p, customer_name: e.target.value }))}
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500" placeholder="z.B. Stadt Muenchen" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Systemtyp</label>
|
||||||
|
<select value={newProject.system_type} onChange={e => setNewProject(p => ({ ...p, system_type: e.target.value }))}
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500">
|
||||||
|
<option value="full_stack">Full Stack (Terminal + Backend)</option>
|
||||||
|
<option value="terminal">Nur Terminal</option>
|
||||||
|
<option value="backend">Nur Backend</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 mt-4">
|
||||||
|
<button onClick={handleCreateAssessment} disabled={!newProject.project_name}
|
||||||
|
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50">Erstellen</button>
|
||||||
|
<button onClick={() => setShowNewAssessment(false)}
|
||||||
|
className="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200">Abbrechen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{assessments.length === 0 ? (
|
||||||
|
<div className="text-center py-12 text-gray-400">
|
||||||
|
<p className="text-lg mb-2">Noch keine Assessments</p>
|
||||||
|
<p className="text-sm">Erstelle ein neues Assessment fuer eine Ausschreibung.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{assessments.map(a => (
|
||||||
|
<div key={a.id} className="bg-white rounded-xl border border-gray-200 p-6">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">{a.project_name}</h3>
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
{a.customer_name && <span>{a.customer_name} · </span>}
|
||||||
|
{a.tender_reference && <span>Ref: {a.tender_reference} · </span>}
|
||||||
|
<span>{new Date(a.created_at).toLocaleDateString('de-DE')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className={`px-3 py-1 rounded-full text-sm font-medium ${
|
||||||
|
a.status === 'completed' ? 'bg-green-100 text-green-700' :
|
||||||
|
a.status === 'in_progress' ? 'bg-yellow-100 text-yellow-700' :
|
||||||
|
'bg-gray-100 text-gray-700'
|
||||||
|
}`}>{a.status}</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-6 gap-2">
|
||||||
|
<div className="text-center p-2 bg-gray-50 rounded">
|
||||||
|
<div className="text-lg font-bold">{a.total_controls}</div>
|
||||||
|
<div className="text-xs text-gray-500">Total</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-2 bg-green-50 rounded">
|
||||||
|
<div className="text-lg font-bold text-green-700">{a.controls_passed}</div>
|
||||||
|
<div className="text-xs text-gray-500">Passed</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-2 bg-red-50 rounded">
|
||||||
|
<div className="text-lg font-bold text-red-700">{a.controls_failed}</div>
|
||||||
|
<div className="text-xs text-gray-500">Failed</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-2 bg-yellow-50 rounded">
|
||||||
|
<div className="text-lg font-bold text-yellow-700">{a.controls_partial}</div>
|
||||||
|
<div className="text-xs text-gray-500">Partial</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-2 bg-gray-50 rounded">
|
||||||
|
<div className="text-lg font-bold text-gray-400">{a.controls_not_applicable}</div>
|
||||||
|
<div className="text-xs text-gray-500">N/A</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-2 bg-gray-50 rounded">
|
||||||
|
<div className="text-lg font-bold text-gray-400">{a.controls_not_checked}</div>
|
||||||
|
<div className="text-xs text-gray-500">Offen</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : tab === 'tender' ? (
|
||||||
|
<>
|
||||||
|
{/* Tender Analysis Tab */}
|
||||||
|
<div className="mb-6 p-6 bg-white rounded-xl border-2 border-dashed border-purple-300 text-center">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">Ausschreibung analysieren</h3>
|
||||||
|
<p className="text-sm text-gray-500 mb-4">
|
||||||
|
Laden Sie ein Ausschreibungsdokument hoch. Die KI extrahiert automatisch alle Anforderungen und matcht sie gegen die Control-Bibliothek.
|
||||||
|
</p>
|
||||||
|
<label className="inline-block px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 cursor-pointer">
|
||||||
|
{uploading ? 'Hochladen...' : processing ? 'Analysiere...' : 'PDF / Dokument hochladen'}
|
||||||
|
<input type="file" className="hidden" accept=".pdf,.txt,.doc,.docx" onChange={handleTenderUpload} disabled={uploading || processing} />
|
||||||
|
</label>
|
||||||
|
<p className="text-xs text-gray-400 mt-2">PDF, TXT oder Word. Max 50 MB. Dokument wird nur fuer diese Analyse verwendet.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Selected Tender Detail */}
|
||||||
|
{selectedTender && (
|
||||||
|
<div className="mb-6 p-6 bg-white rounded-xl border border-purple-200">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">{selectedTender.project_name}</h3>
|
||||||
|
<p className="text-sm text-gray-500">{selectedTender.file_name} — {selectedTender.status}</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setSelectedTender(null)} className="text-gray-400 hover:text-gray-600 text-xl">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-4 gap-3 mb-6">
|
||||||
|
<div className="text-center p-3 bg-gray-50 rounded-lg">
|
||||||
|
<div className="text-2xl font-bold">{selectedTender.total_requirements}</div>
|
||||||
|
<div className="text-xs text-gray-500">Anforderungen</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-3 bg-green-50 rounded-lg">
|
||||||
|
<div className="text-2xl font-bold text-green-700">{selectedTender.matched_count}</div>
|
||||||
|
<div className="text-xs text-gray-500">Abgedeckt</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-3 bg-yellow-50 rounded-lg">
|
||||||
|
<div className="text-2xl font-bold text-yellow-700">{selectedTender.partial_count}</div>
|
||||||
|
<div className="text-xs text-gray-500">Teilweise</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-3 bg-red-50 rounded-lg">
|
||||||
|
<div className="text-2xl font-bold text-red-700">{selectedTender.unmatched_count}</div>
|
||||||
|
<div className="text-xs text-gray-500">Luecken</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Match Results */}
|
||||||
|
{selectedTender.match_results && selectedTender.match_results.length > 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="font-semibold text-gray-900">Requirement → Control Matching</h4>
|
||||||
|
{selectedTender.match_results.map((mr, idx) => (
|
||||||
|
<div key={idx} className={`p-4 rounded-lg border ${
|
||||||
|
mr.verdict === 'matched' ? 'border-green-200 bg-green-50' :
|
||||||
|
mr.verdict === 'partial' ? 'border-yellow-200 bg-yellow-50' :
|
||||||
|
'border-red-200 bg-red-50'
|
||||||
|
}`}>
|
||||||
|
<div className="flex items-start justify-between mb-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="text-xs font-mono bg-white px-2 py-0.5 rounded border">{mr.req_id}</span>
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded-full ${
|
||||||
|
mr.verdict === 'matched' ? 'bg-green-200 text-green-800' :
|
||||||
|
mr.verdict === 'partial' ? 'bg-yellow-200 text-yellow-800' :
|
||||||
|
'bg-red-200 text-red-800'
|
||||||
|
}`}>
|
||||||
|
{mr.verdict === 'matched' ? 'Abgedeckt' : mr.verdict === 'partial' ? 'Teilweise' : 'Luecke'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-900">{mr.req_text}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{mr.matched_controls && mr.matched_controls.length > 0 && (
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1">
|
||||||
|
{mr.matched_controls.map(mc => (
|
||||||
|
<span key={mc.control_id} className="text-xs bg-white border px-2 py-0.5 rounded">
|
||||||
|
{mc.control_id} ({Math.round(mc.relevance * 100)}%)
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{mr.gap_description && (
|
||||||
|
<p className="text-xs text-orange-700 mt-2">{mr.gap_description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Previous Analyses */}
|
||||||
|
{tenderAnalyses.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold text-gray-900 mb-3">Bisherige Analysen</h4>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{tenderAnalyses.map(ta => (
|
||||||
|
<button key={ta.id} onClick={() => handleViewTender(ta.id)}
|
||||||
|
className="w-full text-left bg-white rounded-xl border border-gray-200 p-4 hover:border-purple-300 transition-all">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium text-gray-900">{ta.project_name}</h3>
|
||||||
|
<p className="text-xs text-gray-500">{ta.file_name} — {new Date(ta.created_at).toLocaleDateString('de-DE')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<span className="text-xs bg-green-100 text-green-700 px-2 py-0.5 rounded-full">{ta.matched_count} matched</span>
|
||||||
|
{ta.unmatched_count > 0 && (
|
||||||
|
<span className="text-xs bg-red-100 text-red-700 px-2 py-0.5 rounded-full">{ta.unmatched_count} gaps</span>
|
||||||
|
)}
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded-full ${
|
||||||
|
ta.status === 'matched' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-700'
|
||||||
|
}`}>{ta.status}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -250,4 +250,95 @@ export const STEPS_BETRIEB: SDKFlowStep[] = [
|
|||||||
url: '/sdk/isms',
|
url: '/sdk/isms',
|
||||||
completion: 100,
|
completion: 100,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── Control Pipeline ─────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
id: 'control-library',
|
||||||
|
name: 'Canonical Control Library',
|
||||||
|
nameShort: 'Control Library',
|
||||||
|
package: 'betrieb',
|
||||||
|
seq: 5200,
|
||||||
|
checkpointId: 'CP-CLIB',
|
||||||
|
checkpointType: 'REQUIRED',
|
||||||
|
checkpointReviewer: 'NONE',
|
||||||
|
description: 'Verwaltung der ~33.000 Rich Controls aus dem RAG-Korpus. 7-Stufen-Pipeline mit Lizenz-Gate.',
|
||||||
|
descriptionLong: 'Die Canonical Control Library ist das zentrale Verzeichnis aller aus Regulierungstexten generierten Compliance Controls. Die 7-Stufen-Pipeline verarbeitet ~105.000 RAG-Chunks: (1) RAG Scan, (2) Lizenz-Klassifikation (Rule 1/2/3), (3a) Strukturierung (Rule 1+2) oder (3b) Reformulierung (Rule 3), (4) Harmonisierung (Embedding-Dedup), (5) Anchor Search (Open-Source-Referenzen), (6) Speicherung, (7) Chunk-Tracking. Domains: AUTH, CRYP, NET, DATA, SEC, AI, COMP, GOV, LAB, FIN u.a.',
|
||||||
|
legalBasis: 'UrhG §44b (Text & Data Mining), UrhG §23 (Hinreichender Abstand)',
|
||||||
|
inputs: ['ragChunks'],
|
||||||
|
outputs: ['canonicalControls'],
|
||||||
|
prerequisiteSteps: [],
|
||||||
|
dbTables: ['canonical_controls', 'canonical_processed_chunks', 'canonical_generation_jobs'],
|
||||||
|
dbMode: 'read/write',
|
||||||
|
ragCollections: ['bp_compliance_gesetze', 'bp_compliance_datenschutz', 'bp_compliance_ce', 'bp_dsfa_corpus', 'bp_legal_templates'],
|
||||||
|
ragPurpose: 'Quelldokumente fuer Control-Generierung (Gesetze, Verordnungen, Standards)',
|
||||||
|
isOptional: false,
|
||||||
|
url: '/sdk/control-library',
|
||||||
|
completion: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'obligation-extraction',
|
||||||
|
name: 'Pass 0a: Obligation Extraction',
|
||||||
|
nameShort: 'Pass 0a',
|
||||||
|
package: 'betrieb',
|
||||||
|
seq: 5300,
|
||||||
|
checkpointId: 'CP-P0A',
|
||||||
|
checkpointType: 'REQUIRED',
|
||||||
|
checkpointReviewer: 'NONE',
|
||||||
|
description: 'Extraktion von ~181.000 normativen Pflichten aus Rich Controls via Claude Haiku (Batch API).',
|
||||||
|
descriptionLong: 'Pass 0a zerlegt jeden Rich Control in einzelne normative Obligations via Claude Haiku (Anthropic Batch API, 50% Kostenreduktion). Jede Obligation wird klassifiziert: Pflicht/Empfehlung/Kann, Test-Obligation ja/nein, Reporting-Obligation ja/nein. Quality Gate mit 6 Regeln: nur normative Aussagen, ein Hauptverb, Test/Reporting separat, kein Evidence-Level-Split. Ergebnis: ~181.000 validierte Obligations mit action, object, condition, normative_strength.',
|
||||||
|
legalBasis: 'Pipeline-intern (Normative Obligation Extraction)',
|
||||||
|
inputs: ['canonicalControls'],
|
||||||
|
outputs: ['obligationCandidates'],
|
||||||
|
prerequisiteSteps: ['control-library'],
|
||||||
|
dbTables: ['obligation_candidates'],
|
||||||
|
dbMode: 'read/write',
|
||||||
|
ragCollections: [],
|
||||||
|
isOptional: false,
|
||||||
|
url: '/sdk/control-library',
|
||||||
|
completion: 90,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'atomic-composition',
|
||||||
|
name: 'Pass 0b: Atomic Composition',
|
||||||
|
nameShort: 'Pass 0b',
|
||||||
|
package: 'betrieb',
|
||||||
|
seq: 5400,
|
||||||
|
checkpointId: 'CP-P0B',
|
||||||
|
checkpointType: 'REQUIRED',
|
||||||
|
checkpointReviewer: 'NONE',
|
||||||
|
description: 'Komposition atomarer MCP-tauglicher Controls aus Obligations via Claude Sonnet + Pre-LLM Ontology-Filter.',
|
||||||
|
descriptionLong: 'Pass 0b verwandelt jede validierte Obligation in ein eigenstaendiges atomares Control via Claude Sonnet (Anthropic Batch API). Vor dem LLM-Call klassifiziert die Control Ontology (26 Action Types) jede Obligation: atomic (an LLM senden), composite (ueberspringen), evidence (ueberspringen), framework_container (ueberspringen). MCP-taugliche Output-Felder: assertion (pruefbare Aussage), pass_criteria, fail_criteria, check_type (technical_config_check, document_clause_check, code_pattern_check), dependency_hints, lifecycle_phase_order (1-13). Canonical Key Format: action_type:normalized_object:control_phase.',
|
||||||
|
legalBasis: 'Pipeline-intern (Atomic Control Composition)',
|
||||||
|
inputs: ['obligationCandidates'],
|
||||||
|
outputs: ['atomicControls'],
|
||||||
|
prerequisiteSteps: ['obligation-extraction'],
|
||||||
|
dbTables: ['canonical_controls', 'control_parent_links'],
|
||||||
|
dbMode: 'read/write',
|
||||||
|
ragCollections: [],
|
||||||
|
isOptional: false,
|
||||||
|
url: '/sdk/control-library',
|
||||||
|
completion: 80,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dependency-engine',
|
||||||
|
name: 'Dependency Engine + Evaluation',
|
||||||
|
nameShort: 'Dependencies',
|
||||||
|
package: 'betrieb',
|
||||||
|
seq: 5500,
|
||||||
|
checkpointId: 'CP-DEP',
|
||||||
|
checkpointType: 'REQUIRED',
|
||||||
|
checkpointReviewer: 'NONE',
|
||||||
|
description: '5 Dependency-Typen, generische Condition Language, automatische Generierung via Ontology + Domain Packs.',
|
||||||
|
descriptionLong: 'Die Dependency Engine modelliert logische Abhaengigkeiten zwischen Controls: supersedes (A ersetzt B), prerequisite (A muss vor B), compensating_control (A kompensiert B-Failure), scope_exclusion (A schliesst B aus), conditional_requirement (B nur unter Bedingung). Generische Condition Language (AND/OR/NOT + Feldoperatoren). Priority-basierte Konfliktloesung. Zykluserkennung (DFS). Automatische Generierung via: (1) Ontology (Phase-Sequenz), (2) Pattern-Regeln, (3) Domain Packs (DSGVO, AI Act, CRA, Security, Arbeitsrecht). MCP-Output mit dependency_resolution Trace.',
|
||||||
|
legalBasis: 'Pipeline-intern (Control Dependency Resolution)',
|
||||||
|
inputs: ['atomicControls'],
|
||||||
|
outputs: ['evaluatedControls', 'dependencyGraph'],
|
||||||
|
prerequisiteSteps: ['atomic-composition'],
|
||||||
|
dbTables: ['control_dependencies', 'control_evaluation_results'],
|
||||||
|
dbMode: 'read/write',
|
||||||
|
ragCollections: [],
|
||||||
|
isOptional: false,
|
||||||
|
url: '/sdk/control-library',
|
||||||
|
completion: 100,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export const STEPS_VORBEREITUNG: SDKFlowStep[] = [
|
|||||||
checkpointId: 'CP-UC',
|
checkpointId: 'CP-UC',
|
||||||
checkpointType: 'REQUIRED',
|
checkpointType: 'REQUIRED',
|
||||||
checkpointReviewer: 'NONE',
|
checkpointReviewer: 'NONE',
|
||||||
description: 'Systematische Erfassung aller Datenverarbeitungs- und KI-Anwendungsfaelle ueber einen 8-Schritte-Wizard mit Kachel-Auswahl.',
|
description: 'Systematische Erfassung aller Datenverarbeitungs- und KI-Anwendungsfaelle ueber einen 8-Schritte-Wizard mit Kachel-Auswahl. Inkl. BetrVG-Mitbestimmungspruefung und Betriebsrats-Konflikt-Score.',
|
||||||
descriptionLong: 'In einem 8-Schritte-Wizard werden alle Use Cases erfasst: (1) Grundlegendes — Titel, Beschreibung, KI-Kategorie (21 Kacheln), Branche wird automatisch aus dem Profil abgeleitet. (2) Datenkategorien — ~60 Kategorien in 10 Gruppen als Kacheln (inkl. Art. 9 hervorgehoben). (3) Verarbeitungszweck — 16 Zweck-Kacheln, Rechtsgrundlage wird vom SDK automatisch ermittelt. (4) Automatisierungsgrad — assistiv/teilautomatisiert/vollautomatisiert. (5) Hosting & Modell — Provider, Region, Modellnutzung (Inferenz/RAG/Fine-Tuning/Training). (6) Datentransfer — Transferziele und Schutzmechanismen. (7) Datenhaltung — Aufbewahrungsfristen. (8) Vertraege — vorhandene Compliance-Dokumente. Die RAG-Collection bp_compliance_ce wird verwendet, um relevante CE-Regulierungen automatisch den Use Cases zuzuordnen (UCCA).',
|
descriptionLong: 'In einem 8-Schritte-Wizard werden alle Use Cases erfasst: (1) Grundlegendes — Titel, Beschreibung, KI-Kategorie (21 Kacheln), Branche wird automatisch aus dem Profil abgeleitet. (2) Datenkategorien — ~60 Kategorien in 10 Gruppen als Kacheln (inkl. Art. 9 hervorgehoben). (3) Verarbeitungszweck — 16 Zweck-Kacheln, Rechtsgrundlage wird vom SDK automatisch ermittelt. (4) Automatisierungsgrad — assistiv/teilautomatisiert/vollautomatisiert. (5) Hosting & Modell — Provider, Region, Modellnutzung (Inferenz/RAG/Fine-Tuning/Training). (6) Datentransfer — Transferziele und Schutzmechanismen. (7) Datenhaltung — Aufbewahrungsfristen. (8) Vertraege — vorhandene Compliance-Dokumente. Die RAG-Collection bp_compliance_ce wird verwendet, um relevante CE-Regulierungen automatisch den Use Cases zuzuordnen (UCCA).',
|
||||||
legalBasis: 'Art. 30 DSGVO (Verzeichnis von Verarbeitungstaetigkeiten)',
|
legalBasis: 'Art. 30 DSGVO (Verzeichnis von Verarbeitungstaetigkeiten)',
|
||||||
inputs: ['companyProfile'],
|
inputs: ['companyProfile'],
|
||||||
@@ -66,6 +66,27 @@ export const STEPS_VORBEREITUNG: SDKFlowStep[] = [
|
|||||||
isOptional: false,
|
isOptional: false,
|
||||||
url: '/sdk/use-cases',
|
url: '/sdk/use-cases',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'ai-registration',
|
||||||
|
name: 'EU AI Database Registrierung',
|
||||||
|
nameShort: 'EU-Reg',
|
||||||
|
package: 'vorbereitung',
|
||||||
|
seq: 350,
|
||||||
|
checkpointId: 'CP-REG',
|
||||||
|
checkpointType: 'CONDITIONAL',
|
||||||
|
checkpointReviewer: 'NONE',
|
||||||
|
description: 'Registrierung von Hochrisiko-KI-Systemen in der EU AI Database gemaess Art. 49 KI-Verordnung.',
|
||||||
|
descriptionLong: 'Fuer Hochrisiko-KI-Systeme (Annex III) ist eine Registrierung in der EU AI Database Pflicht. Ein 6-Schritte-Wizard fuehrt durch den Prozess: (1) Anbieter-Daten — Name, Rechtsform, Adresse, EU-Repraesentant. (2) System-Details — Name, Version, Beschreibung, Einsatzzweck (vorausgefuellt aus UCCA Assessment). (3) Klassifikation — Risikoklasse und Annex III Kategorie (aus Decision Tree). (4) Konformitaet — CE-Kennzeichnung, Notified Body. (5) Trainingsdaten — Zusammenfassung der Datenquellen (Art. 10). (6) Pruefung + Export — JSON-Download fuer EU-Datenbank-Submission. Der Status-Workflow ist: Entwurf → Bereit → Eingereicht → Registriert.',
|
||||||
|
legalBasis: 'Art. 49 KI-Verordnung (EU) 2024/1689',
|
||||||
|
inputs: ['useCases', 'companyProfile'],
|
||||||
|
outputs: ['euRegistration'],
|
||||||
|
prerequisiteSteps: ['use-case-assessment'],
|
||||||
|
dbTables: ['ai_system_registrations'],
|
||||||
|
dbMode: 'read/write',
|
||||||
|
ragCollections: [],
|
||||||
|
isOptional: true,
|
||||||
|
url: '/sdk/ai-registration',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'import',
|
id: 'import',
|
||||||
name: 'Dokument-Import',
|
name: 'Dokument-Import',
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import React, { useState, useEffect } from 'react'
|
|||||||
import { useParams, useRouter } from 'next/navigation'
|
import { useParams, useRouter } from 'next/navigation'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { AssessmentResultCard } from '@/components/sdk/use-case-assessment/AssessmentResultCard'
|
import { AssessmentResultCard } from '@/components/sdk/use-case-assessment/AssessmentResultCard'
|
||||||
|
import { OptimizerUpsellCard } from '@/components/sdk/compliance-optimizer/OptimizerUpsellCard'
|
||||||
|
import { EnrichmentHints } from '@/components/sdk/assessment/EnrichmentHints'
|
||||||
|
|
||||||
interface TriggeredRule {
|
interface TriggeredRule {
|
||||||
code: string
|
code: string
|
||||||
@@ -57,6 +59,8 @@ interface FullAssessment {
|
|||||||
dsfa_recommended: boolean
|
dsfa_recommended: boolean
|
||||||
art22_risk: boolean
|
art22_risk: boolean
|
||||||
training_allowed: string
|
training_allowed: string
|
||||||
|
betrvg_conflict_score?: number
|
||||||
|
betrvg_consultation_required?: boolean
|
||||||
triggered_rules?: TriggeredRule[]
|
triggered_rules?: TriggeredRule[]
|
||||||
required_controls?: RequiredControl[]
|
required_controls?: RequiredControl[]
|
||||||
recommended_architecture?: PatternRecommendation[]
|
recommended_architecture?: PatternRecommendation[]
|
||||||
@@ -136,6 +140,18 @@ export default function AssessmentDetailPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [optimizing, setOptimizing] = useState(false)
|
||||||
|
const handleOptimize = async () => {
|
||||||
|
setOptimizing(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/sdk/v1/maximizer/optimize-from-assessment/${assessmentId}`, { method: 'POST' })
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
router.push(`/sdk/compliance-optimizer/${data.id}`)
|
||||||
|
}
|
||||||
|
} catch { /* silent */ } finally { setOptimizing(false) }
|
||||||
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-64">
|
<div className="flex items-center justify-center h-64">
|
||||||
@@ -167,6 +183,8 @@ export default function AssessmentDetailPage() {
|
|||||||
dsfa_recommended: assessment.dsfa_recommended,
|
dsfa_recommended: assessment.dsfa_recommended,
|
||||||
art22_risk: assessment.art22_risk,
|
art22_risk: assessment.art22_risk,
|
||||||
training_allowed: assessment.training_allowed,
|
training_allowed: assessment.training_allowed,
|
||||||
|
betrvg_conflict_score: assessment.betrvg_conflict_score,
|
||||||
|
betrvg_consultation_required: assessment.betrvg_consultation_required,
|
||||||
// AssessmentResultCard expects rule_code; backend stores code — map here
|
// AssessmentResultCard expects rule_code; backend stores code — map here
|
||||||
triggered_rules: assessment.triggered_rules?.map(r => ({
|
triggered_rules: assessment.triggered_rules?.map(r => ({
|
||||||
rule_code: r.code,
|
rule_code: r.code,
|
||||||
@@ -230,6 +248,13 @@ export default function AssessmentDetailPage() {
|
|||||||
>
|
>
|
||||||
↓ JSON
|
↓ JSON
|
||||||
</a>
|
</a>
|
||||||
|
<button
|
||||||
|
onClick={handleOptimize}
|
||||||
|
disabled={optimizing}
|
||||||
|
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{optimizing ? 'Optimiere...' : 'Optimieren'}
|
||||||
|
</button>
|
||||||
<Link
|
<Link
|
||||||
href={`/sdk/use-cases/new?edit=${assessmentId}`}
|
href={`/sdk/use-cases/new?edit=${assessmentId}`}
|
||||||
className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||||
@@ -269,6 +294,18 @@ export default function AssessmentDetailPage() {
|
|||||||
{/* Result */}
|
{/* Result */}
|
||||||
<AssessmentResultCard result={resultForCard as Parameters<typeof AssessmentResultCard>[0]['result']} />
|
<AssessmentResultCard result={resultForCard as Parameters<typeof AssessmentResultCard>[0]['result']} />
|
||||||
|
|
||||||
|
{/* Enrichment Hints */}
|
||||||
|
{assessment.enrichment_hints && (
|
||||||
|
<EnrichmentHints hints={assessment.enrichment_hints} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Compliance Optimizer Upsell */}
|
||||||
|
<OptimizerUpsellCard
|
||||||
|
feasibility={assessment.feasibility}
|
||||||
|
assessmentId={assessmentId}
|
||||||
|
riskScore={assessment.risk_score}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* KI-Erklärung */}
|
{/* KI-Erklärung */}
|
||||||
{assessment.explanation_text && (
|
{assessment.explanation_text && (
|
||||||
<div className="bg-purple-50 border border-purple-200 rounded-xl p-6">
|
<div className="bg-purple-50 border border-purple-200 rounded-xl p-6">
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ interface Assessment {
|
|||||||
feasibility: string
|
feasibility: string
|
||||||
risk_level: string
|
risk_level: string
|
||||||
risk_score: number
|
risk_score: number
|
||||||
|
betrvg_conflict_score?: number
|
||||||
|
betrvg_consultation_required?: boolean
|
||||||
domain: string
|
domain: string
|
||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
@@ -194,6 +196,16 @@ export default function UseCasesPage() {
|
|||||||
<span className={`px-2 py-0.5 text-xs rounded-full ${feasibility.bg} ${feasibility.text}`}>
|
<span className={`px-2 py-0.5 text-xs rounded-full ${feasibility.bg} ${feasibility.text}`}>
|
||||||
{feasibility.label}
|
{feasibility.label}
|
||||||
</span>
|
</span>
|
||||||
|
{assessment.betrvg_conflict_score != null && assessment.betrvg_conflict_score > 0 && (
|
||||||
|
<span className={`px-2 py-0.5 text-xs rounded-full ${
|
||||||
|
assessment.betrvg_conflict_score >= 75 ? 'bg-red-100 text-red-700' :
|
||||||
|
assessment.betrvg_conflict_score >= 50 ? 'bg-orange-100 text-orange-700' :
|
||||||
|
assessment.betrvg_conflict_score >= 25 ? 'bg-yellow-100 text-yellow-700' :
|
||||||
|
'bg-green-100 text-green-700'
|
||||||
|
}`}>
|
||||||
|
BR {assessment.betrvg_conflict_score}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4 text-sm text-gray-500">
|
<div className="flex items-center gap-4 text-sm text-gray-500">
|
||||||
<span>{assessment.domain}</span>
|
<span>{assessment.domain}</span>
|
||||||
|
|||||||
@@ -0,0 +1,368 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||||
|
import {
|
||||||
|
CATEGORY_VENDORS, countNonEWRVendors, isEWR, isOutsideEWR,
|
||||||
|
type VendorInfo,
|
||||||
|
} from './cookie-banner-vendors'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CookieBannerOverlay — DSGVO/CNIL-konformer Cookie-Banner mit "Nur EU/EWR" Toggle.
|
||||||
|
*
|
||||||
|
* Alle 4 Kategorien sind auf der ersten Ebene sichtbar (DSK OH Telemedien 2022).
|
||||||
|
* Vendor-Details aufklappbar per Kategorie. EWR-Toggle blockiert Non-EU-Anbieter
|
||||||
|
* auch bei aktivierter Kategorie — einzigartiges CMP-Feature.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'bp-sdk-cookie-consent'
|
||||||
|
|
||||||
|
interface ConsentState {
|
||||||
|
necessary: boolean
|
||||||
|
statistics: boolean
|
||||||
|
marketing: boolean
|
||||||
|
functional: boolean
|
||||||
|
ewrOnly: boolean
|
||||||
|
blockedVendors: string[]
|
||||||
|
timestamp: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStoredConsent(): ConsentState | null {
|
||||||
|
if (typeof window === 'undefined') return null
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (!raw) return null
|
||||||
|
return JSON.parse(raw)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CookieBannerOverlay() {
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
const [consent, setConsent] = useState<ConsentState>({
|
||||||
|
necessary: true, statistics: false, marketing: false, functional: false,
|
||||||
|
ewrOnly: false, blockedVendors: [], timestamp: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const nonEWRCount = useMemo(() => countNonEWRVendors(), [])
|
||||||
|
|
||||||
|
const blockedVendors = useMemo(() => {
|
||||||
|
if (!consent.ewrOnly) return []
|
||||||
|
const blocked: string[] = []
|
||||||
|
for (const [key, cat] of Object.entries(CATEGORY_VENDORS)) {
|
||||||
|
const catEnabled = key === 'necessary' || consent[key as keyof ConsentState]
|
||||||
|
if (!catEnabled) continue
|
||||||
|
for (const v of cat.vendors) {
|
||||||
|
if (isOutsideEWR(v.country)) blocked.push(v.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return blocked
|
||||||
|
}, [consent])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const stored = getStoredConsent()
|
||||||
|
if (!stored) setIsOpen(true)
|
||||||
|
else setConsent(stored)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = () => setIsOpen(true)
|
||||||
|
window.addEventListener('openCookieBanner', handler)
|
||||||
|
return () => window.removeEventListener('openCookieBanner', handler)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const saveConsent = useCallback((state: ConsentState) => {
|
||||||
|
const blocked: string[] = []
|
||||||
|
if (state.ewrOnly) {
|
||||||
|
for (const [key, cat] of Object.entries(CATEGORY_VENDORS)) {
|
||||||
|
const catEnabled = key === 'necessary' || state[key as keyof ConsentState]
|
||||||
|
if (!catEnabled) continue
|
||||||
|
for (const v of cat.vendors) {
|
||||||
|
if (isOutsideEWR(v.country)) blocked.push(v.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const withMeta = { ...state, blockedVendors: blocked, timestamp: new Date().toISOString() }
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(withMeta))
|
||||||
|
setConsent(withMeta)
|
||||||
|
setIsOpen(false)
|
||||||
|
window.dispatchEvent(new CustomEvent('sdkCookieConsentUpdated', { detail: withMeta }))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (!isOpen) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Non-blocking banner — no overlay, no pointer-events blocking */}
|
||||||
|
<div className="fixed bottom-0 left-16 xl:left-64 right-0 z-50 pointer-events-none">
|
||||||
|
<div className="max-w-3xl mx-auto m-4 bg-white rounded-2xl shadow-2xl border border-gray-200 overflow-hidden pointer-events-auto">
|
||||||
|
|
||||||
|
{/* Header with EWR toggle + close button */}
|
||||||
|
<div className="px-6 pt-5 pb-3">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 flex items-center justify-between">
|
||||||
|
Cookie-Einstellungen
|
||||||
|
<button onClick={() => setIsOpen(false)} className="text-gray-400 hover:text-gray-600 p-1" aria-label="Schliessen">
|
||||||
|
<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>
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-600 mt-1">
|
||||||
|
Waehlen Sie, welche Cookie-Kategorien Sie zulassen moechten.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<EWRToggle
|
||||||
|
checked={consent.ewrOnly}
|
||||||
|
onChange={() => setConsent(prev => ({ ...prev, ewrOnly: !prev.ewrOnly }))}
|
||||||
|
blockedCount={blockedVendors.length}
|
||||||
|
nonEWRCount={nonEWRCount}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Categories — always visible (CNIL/DSK compliant) */}
|
||||||
|
<div className="px-6 pb-3 space-y-1.5 max-h-[45vh] overflow-y-auto border-t border-gray-100 pt-3">
|
||||||
|
{Object.entries(CATEGORY_VENDORS).map(([key, cat]) => (
|
||||||
|
<CategorySection
|
||||||
|
key={key}
|
||||||
|
label={cat.label}
|
||||||
|
description={cat.description}
|
||||||
|
vendors={cat.vendors}
|
||||||
|
checked={key === 'necessary' ? true : consent[key as keyof ConsentState] as boolean}
|
||||||
|
disabled={key === 'necessary'}
|
||||||
|
ewrOnly={consent.ewrOnly}
|
||||||
|
onChange={(v) => key !== 'necessary' && setConsent(prev => ({ ...prev, [key]: v }))}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Buttons — two equal-weight options */}
|
||||||
|
<div className="px-6 py-4 bg-gray-50 border-t border-gray-100">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => saveConsent({ ...consent, necessary: true, statistics: true, marketing: true, functional: true })}
|
||||||
|
className="flex-1 px-4 py-2.5 bg-purple-600 text-white rounded-lg font-medium hover:bg-purple-700 transition-colors text-sm"
|
||||||
|
>
|
||||||
|
Alle akzeptieren
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => saveConsent(consent)}
|
||||||
|
className="flex-1 px-4 py-2.5 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300 transition-colors text-sm"
|
||||||
|
>
|
||||||
|
Auswahl speichern
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between mt-3">
|
||||||
|
<button
|
||||||
|
onClick={() => saveConsent({ ...consent, necessary: true, statistics: false, marketing: false, functional: false })}
|
||||||
|
className="text-xs text-gray-500 hover:text-gray-700 underline"
|
||||||
|
>
|
||||||
|
Nur notwendige Cookies
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center gap-3 text-xs text-gray-400">
|
||||||
|
<a href="/sdk/einwilligungen/privacy-policy" className="hover:text-purple-600 underline">
|
||||||
|
Datenschutzerklaerung
|
||||||
|
</a>
|
||||||
|
<a href="/sdk/document-generator" className="hover:text-purple-600 underline">
|
||||||
|
Impressum
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function CookieBannerFAB() {
|
||||||
|
const [hasConsent, setHasConsent] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setHasConsent(!!getStoredConsent())
|
||||||
|
const handler = () => setHasConsent(true)
|
||||||
|
window.addEventListener('sdkCookieConsentUpdated', handler)
|
||||||
|
return () => window.removeEventListener('sdkCookieConsentUpdated', handler)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (!hasConsent) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => window.dispatchEvent(new Event('openCookieBanner'))}
|
||||||
|
className="fixed bottom-6 right-[10rem] w-14 h-14 bg-gray-700 hover:bg-gray-800 text-white rounded-full shadow-lg flex items-center justify-center transition-all duration-200 hover:scale-110 z-50"
|
||||||
|
aria-label="Cookie-Einstellungen oeffnen"
|
||||||
|
title="Cookie-Einstellungen"
|
||||||
|
>
|
||||||
|
<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-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>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ─── EWR Toggle with Info Button ──────────────────────────
|
||||||
|
|
||||||
|
function EWRToggle({ checked, onChange, blockedCount, nonEWRCount }: {
|
||||||
|
checked: boolean; onChange: () => void; blockedCount: number; nonEWRCount: number
|
||||||
|
}) {
|
||||||
|
const [showInfo, setShowInfo] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex flex-col items-end gap-1 shrink-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowInfo(!showInfo)}
|
||||||
|
className="w-5 h-5 rounded-full bg-blue-100 text-blue-600 hover:bg-blue-200 flex items-center justify-center text-xs font-bold"
|
||||||
|
aria-label="Info zu Nur EU/EWR"
|
||||||
|
>
|
||||||
|
i
|
||||||
|
</button>
|
||||||
|
<span className={`text-xs font-medium whitespace-nowrap ${checked ? 'text-blue-700' : 'text-gray-500'}`}>
|
||||||
|
Nur EU/EWR
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={onChange}
|
||||||
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors shrink-0 cursor-pointer ${
|
||||||
|
checked ? 'bg-blue-600' : 'bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform ${
|
||||||
|
checked ? 'translate-x-6' : 'translate-x-1'
|
||||||
|
}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{checked && blockedCount > 0 && (
|
||||||
|
<span className="text-[10px] text-red-600 font-medium">{blockedCount} blockiert</span>
|
||||||
|
)}
|
||||||
|
{showInfo && (
|
||||||
|
<div className="absolute right-0 top-12 w-72 p-3 bg-blue-50 border border-blue-200 rounded-lg shadow-lg z-10 text-xs text-blue-800 leading-relaxed">
|
||||||
|
<div className="font-semibold mb-1">Nur EU/EWR-Anbieter</div>
|
||||||
|
<p>
|
||||||
|
Erlaubt nur Anbieter mit Sitz im EWR (EU + Island, Liechtenstein, Norwegen) oder
|
||||||
|
der Schweiz. {nonEWRCount} Anbieter ausserhalb werden blockiert — auch bei
|
||||||
|
aktivierter Cookie-Kategorie.
|
||||||
|
</p>
|
||||||
|
<button onClick={() => setShowInfo(false)} className="mt-2 text-blue-600 hover:text-blue-800 font-medium">
|
||||||
|
Verstanden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ─── Category Section with Vendor Table ───────────────────
|
||||||
|
|
||||||
|
function CategorySection({ label, description, vendors, checked, disabled, ewrOnly, onChange }: {
|
||||||
|
label: string; description: string; vendors: VendorInfo[]; checked: boolean
|
||||||
|
disabled?: boolean; ewrOnly: boolean; onChange: (v: boolean) => void
|
||||||
|
}) {
|
||||||
|
const [expanded, setExpanded] = useState(false)
|
||||||
|
const nonEuVendors = vendors.filter(v => isOutsideEWR(v.country))
|
||||||
|
const blockedCount = ewrOnly && checked ? nonEuVendors.length : 0
|
||||||
|
const activeCount = checked ? vendors.length - blockedCount : 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border border-gray-100 rounded-lg overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between gap-3 px-4 py-2.5 bg-gray-50/50">
|
||||||
|
<button onClick={() => setExpanded(!expanded)} className="flex items-center gap-2 flex-1 text-left">
|
||||||
|
<svg className={`w-3.5 h-3.5 text-gray-400 transition-transform ${expanded ? 'rotate-90' : ''}`}
|
||||||
|
fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-gray-900">
|
||||||
|
{label}
|
||||||
|
<span className="ml-2 text-xs font-normal text-gray-400">
|
||||||
|
{checked && blockedCount > 0
|
||||||
|
? `${activeCount} aktiv, ${blockedCount} blockiert`
|
||||||
|
: `${vendors.length} Verarbeiter`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500">{description}</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => !disabled && onChange(!checked)}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors shrink-0 ${
|
||||||
|
checked ? (disabled ? 'bg-gray-400' : 'bg-purple-600') : 'bg-gray-200'
|
||||||
|
} ${disabled ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'}`}
|
||||||
|
>
|
||||||
|
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform ${
|
||||||
|
checked ? 'translate-x-6' : 'translate-x-1'
|
||||||
|
}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expanded && (
|
||||||
|
<div className="px-4 pb-2.5">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-gray-400 border-b border-gray-100">
|
||||||
|
<th className="text-left py-1 font-medium w-5"></th>
|
||||||
|
<th className="text-left py-1 font-medium">Verarbeiter</th>
|
||||||
|
<th className="text-left py-1 font-medium">Cookies</th>
|
||||||
|
<th className="text-left py-1 font-medium">Dauer</th>
|
||||||
|
<th className="text-left py-1 font-medium">Land</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{vendors.map((v, i) => {
|
||||||
|
const blocked = ewrOnly && checked && isOutsideEWR(v.country)
|
||||||
|
const active = checked && !blocked
|
||||||
|
return (
|
||||||
|
<tr key={i} className={`border-b border-gray-50 last:border-0 ${blocked ? 'opacity-40' : ''}`}>
|
||||||
|
<td className="py-1 w-5">
|
||||||
|
{blocked ? (
|
||||||
|
<svg className="w-3.5 h-3.5 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
|
||||||
|
</svg>
|
||||||
|
) : active ? (
|
||||||
|
<svg className="w-3.5 h-3.5 text-green-500" 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-3.5 h-3.5 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 12H4" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className={`py-1 font-medium ${blocked ? 'line-through text-gray-400' : 'text-gray-700'}`}>{v.name}</td>
|
||||||
|
<td className={`py-1 font-mono ${blocked ? 'line-through text-gray-300' : 'text-gray-500'}`}>{v.cookies}</td>
|
||||||
|
<td className="py-1 text-gray-500">{v.retention}</td>
|
||||||
|
<td className="py-1">
|
||||||
|
{isOutsideEWR(v.country) ? (
|
||||||
|
<span className={`inline-flex items-center gap-1 ${blocked ? 'text-red-400' : 'text-amber-600'}`}>
|
||||||
|
<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-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||||
|
</svg>
|
||||||
|
{v.country}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-green-600 flex items-center gap-1">
|
||||||
|
<svg className="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||||
|
<circle cx="12" cy="12" r="10" strokeWidth={2} />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4" />
|
||||||
|
</svg>
|
||||||
|
{v.country}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -20,6 +20,24 @@ export function SidebarModuleList({ collapsed, projectId, pendingCRCount }: Side
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{/* CMP — Consent Management Platform */}
|
||||||
|
<div className="border-t-2 border-purple-200 py-2 bg-purple-50/30">
|
||||||
|
{!collapsed && (
|
||||||
|
<div className="px-4 py-2 text-xs font-semibold text-purple-600 uppercase tracking-wider">
|
||||||
|
CMP
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<AdditionalModuleItem href="/sdk/cmp" icon={<svg className="w-5 h-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>} label="Dashboard" isActive={pathname === '/sdk/cmp'} collapsed={collapsed} projectId={projectId} />
|
||||||
|
<AdditionalModuleItem href="/sdk/cookie-banner" icon={<svg className="w-5 h-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>} label="Cookie-Banner" isActive={pathname?.startsWith('/sdk/cookie-banner') ?? false} collapsed={collapsed} projectId={projectId} />
|
||||||
|
<AdditionalModuleItem href="/sdk/cookie-banner/preview" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></svg>} label="Live-Vorschau" isActive={pathname === '/sdk/cookie-banner/preview'} collapsed={collapsed} projectId={projectId} />
|
||||||
|
<AdditionalModuleItem href="/sdk/einwilligungen" icon={<svg className="w-5 h-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>} label="Consent-Records" isActive={pathname?.startsWith('/sdk/einwilligungen') ?? false} collapsed={collapsed} projectId={projectId} />
|
||||||
|
<AdditionalModuleItem href="/sdk/consent-management" icon={<svg className="w-5 h-5" 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>} label="Consent-Verwaltung" isActive={pathname === '/sdk/consent-management'} collapsed={collapsed} projectId={projectId} />
|
||||||
|
<AdditionalModuleItem href="/sdk/vendor-compliance" icon={<svg className="w-5 h-5" 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>} label="Vendor-Compliance" isActive={pathname?.startsWith('/sdk/vendor-compliance') ?? false} collapsed={collapsed} projectId={projectId} />
|
||||||
|
<AdditionalModuleItem href="/sdk/dsr" icon={<svg className="w-5 h-5" 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>} label="DSR Portal" isActive={pathname?.startsWith('/sdk/dsr') ?? false} collapsed={collapsed} projectId={projectId} />
|
||||||
|
<AdditionalModuleItem href="/sdk/loeschfristen" icon={<svg className="w-5 h-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>} label="Loeschfristen" isActive={pathname === '/sdk/loeschfristen'} collapsed={collapsed} projectId={projectId} />
|
||||||
|
<AdditionalModuleItem href="/sdk/email-templates" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg>} label="E-Mail-Templates" isActive={pathname === '/sdk/email-templates'} collapsed={collapsed} projectId={projectId} />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Maschinenrecht / CE */}
|
{/* Maschinenrecht / CE */}
|
||||||
<div className="border-t border-gray-100 py-2">
|
<div className="border-t border-gray-100 py-2">
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
@@ -42,6 +60,31 @@ export function SidebarModuleList({ collapsed, projectId, pendingCRCount }: Side
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* KI-Compliance */}
|
||||||
|
<div className="border-t border-gray-100 py-2">
|
||||||
|
{!collapsed && (
|
||||||
|
<div className="px-4 py-2 text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||||
|
KI-Compliance
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<AdditionalModuleItem href="/sdk/advisory-board" icon={<svg className="w-5 h-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 2" /></svg>} label="Use Case Erfassung" isActive={pathname === '/sdk/advisory-board'} collapsed={collapsed} projectId={projectId} />
|
||||||
|
<AdditionalModuleItem href="/sdk/use-cases" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h16M4 18h16" /></svg>} label="Use Cases" isActive={pathname?.startsWith('/sdk/use-cases') ?? false} collapsed={collapsed} projectId={projectId} />
|
||||||
|
<AdditionalModuleItem href="/sdk/ai-act" icon={<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>} label="AI Act" isActive={pathname?.startsWith('/sdk/ai-act') ?? false} collapsed={collapsed} projectId={projectId} />
|
||||||
|
<AdditionalModuleItem href="/sdk/ai-registration" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" /></svg>} label="EU Registrierung" isActive={pathname?.startsWith('/sdk/ai-registration') ?? false} collapsed={collapsed} projectId={projectId} />
|
||||||
|
<AdditionalModuleItem href="/sdk/compliance-optimizer" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" /></svg>} label="Compliance Optimizer" isActive={pathname?.startsWith('/sdk/compliance-optimizer') ?? false} collapsed={collapsed} projectId={projectId} />
|
||||||
|
<AdditionalModuleItem href="/sdk/agent" icon={<svg className="w-5 h-5" 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>} label="Compliance Agent" isActive={pathname?.startsWith('/sdk/agent') ?? false} collapsed={collapsed} projectId={projectId} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Payment / Terminal */}
|
||||||
|
<div className="border-t border-gray-100 py-2">
|
||||||
|
{!collapsed && (
|
||||||
|
<div className="px-4 py-2 text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||||
|
Payment / Terminal
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<AdditionalModuleItem href="/sdk/payment-compliance" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" /></svg>} label="Payment Compliance" isActive={pathname?.startsWith('/sdk/payment-compliance') ?? false} collapsed={collapsed} projectId={projectId} />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Additional Modules */}
|
{/* Additional Modules */}
|
||||||
<div className="border-t border-gray-100 py-2">
|
<div className="border-t border-gray-100 py-2">
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user