Compare commits
245 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 751f4a5ee7 | |||
| 445a2f7c7c | |||
| c89e46a828 | |||
| 9034a3071c | |||
| 55e44df256 | |||
| e5dcb5a2dc | |||
| 1502ac6d8f | |||
| 0fcb3ee488 | |||
| 499210eff2 | |||
| c6229a2c22 | |||
| c27022d11b | |||
| 51d91d20ed | |||
| 8087e74e88 | |||
| 686834cea0 | |||
| 89af88ef7d | |||
| c4532049d8 | |||
| 1b5c6bd340 | |||
| 5236864521 | |||
| 63bd6a7c6d | |||
| 6cec1dcdba | |||
| 136dc4d553 | |||
| 21c01d6405 | |||
| a708d139ab | |||
| a3a83e5677 | |||
| 3efc491ec5 | |||
| 608fb7faf5 | |||
| 78d7273b82 | |||
| 969658261f | |||
| 58a3fb285f | |||
| 313ee5073b | |||
| 7c17321089 | |||
| 5be1c171cb | |||
| e50f3dfbee | |||
| a2f8366171 | |||
| a3671d4a06 | |||
| cd5f986489 | |||
| a4b75dc6b1 | |||
| a1b9273649 | |||
| ac624f2e9b | |||
| a93ba9ee40 | |||
| 5244500af6 | |||
| f51671737a | |||
| 1cc0c3d34a | |||
| 6e71996733 | |||
| 4f29e5ff3c | |||
| 1d75bbf4eb | |||
| a3287cd5e6 | |||
| 56892cf7dc | |||
| fa4fd87102 | |||
| f59f810638 | |||
| 86504ef280 | |||
| 3d7b09bcef | |||
| 71802614cc | |||
| 30236638ed | |||
| 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 |
@@ -91,6 +91,19 @@ scripts/qa/pdf_qa_all.py
|
||||
scripts/qa/benchmark_llm_controls.py
|
||||
backend-compliance/scripts/seed_policy_templates.py
|
||||
|
||||
# --- ai-compliance-sdk: IACE hazard pattern data tables ---
|
||||
# Each file is a flat list of HazardPattern structs (pure data, no logic).
|
||||
# 85 patterns × 12 lines/pattern = ~1020 lines. Cannot be split meaningfully.
|
||||
ai-compliance-sdk/internal/iace/hazard_patterns_extended3.go
|
||||
ai-compliance-sdk/internal/iace/hazard_patterns_final_a.go
|
||||
ai-compliance-sdk/internal/iace/hazard_patterns_final_b.go
|
||||
ai-compliance-sdk/internal/iace/hazard_patterns_final_c.go
|
||||
ai-compliance-sdk/internal/iace/hazard_patterns_final_d.go
|
||||
ai-compliance-sdk/internal/iace/hazard_patterns_cyber_extended.go
|
||||
ai-compliance-sdk/internal/iace/hazard_patterns_workshop.go
|
||||
ai-compliance-sdk/internal/iace/norms_library_c_process.go
|
||||
ai-compliance-sdk/internal/iace/norms_library_c_food_pkg.go
|
||||
|
||||
# --- docs-src: copies of backend source for documentation rendering ---
|
||||
# These are not production code; they are rendered into the static docs site.
|
||||
docs-src/control_generator.py
|
||||
|
||||
@@ -184,6 +184,29 @@ jobs:
|
||||
docker push registry.meghsakha.com/breakpilot/compliance-dsms-gateway:latest
|
||||
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) ─────────────────────────
|
||||
|
||||
trigger-orca:
|
||||
@@ -197,6 +220,7 @@ jobs:
|
||||
- build-tts
|
||||
- build-document-crawler
|
||||
- build-dsms-gateway
|
||||
- build-dsms-node
|
||||
steps:
|
||||
- name: Checkout (for SHA)
|
||||
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.
|
||||
- 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
|
||||
- Bei Fragen ausserhalb des Kompetenzbereichs: Hoeflich ablehnen und auf Fachanwalt verweisen
|
||||
- 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,42 @@
|
||||
/**
|
||||
* Banner Check API Proxy — calls consent-tester /scan endpoint
|
||||
*
|
||||
* POST /api/sdk/v1/agent/banner-check → runs 3-phase cookie banner test
|
||||
*/
|
||||
|
||||
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.json()
|
||||
const { url } = body
|
||||
|
||||
if (!url) {
|
||||
return NextResponse.json({ error: 'URL erforderlich' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Call backend which proxies to consent-tester
|
||||
const response = await fetch(`${BACKEND_URL}/api/compliance/agent/banner-check`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url }),
|
||||
signal: AbortSignal.timeout(120000), // 2 min for Playwright
|
||||
})
|
||||
|
||||
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) {
|
||||
const msg = error instanceof Error ? error.message : 'Unknown error'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -30,15 +30,15 @@ async function proxyRequest(
|
||||
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')
|
||||
if (tenantHeader) {
|
||||
headers['X-Tenant-Id'] = tenantHeader
|
||||
}
|
||||
headers['X-Tenant-Id'] = tenantHeader || DEFAULT_TENANT
|
||||
|
||||
const userHeader = request.headers.get('x-user-id')
|
||||
if (userHeader) {
|
||||
headers['X-User-Id'] = userHeader
|
||||
}
|
||||
headers['X-User-Id'] = userHeader || DEFAULT_USER
|
||||
|
||||
const fetchOptions: RequestInit = {
|
||||
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
|
||||
|
||||
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({
|
||||
connectionString,
|
||||
connectionString: cleanUrl,
|
||||
max: 5,
|
||||
// Set search_path for compliance schema
|
||||
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'
|
||||
|
||||
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
|
||||
@@ -16,9 +17,7 @@ export async function GET(
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(request.headers.get('X-Tenant-ID') && {
|
||||
'X-Tenant-ID': request.headers.get('X-Tenant-ID') as string,
|
||||
}),
|
||||
'X-Tenant-ID': request.headers.get('X-Tenant-ID') || DEFAULT_TENANT_ID,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -56,9 +55,7 @@ export async function PUT(
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(request.headers.get('X-Tenant-ID') && {
|
||||
'X-Tenant-ID': request.headers.get('X-Tenant-ID') as string,
|
||||
}),
|
||||
'X-Tenant-ID': request.headers.get('X-Tenant-ID') || DEFAULT_TENANT_ID,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
@@ -96,9 +93,7 @@ export async function DELETE(
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(request.headers.get('X-Tenant-ID') && {
|
||||
'X-Tenant-ID': request.headers.get('X-Tenant-ID') as string,
|
||||
}),
|
||||
'X-Tenant-ID': request.headers.get('X-Tenant-ID') || DEFAULT_TENANT_ID,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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: 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',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(request.headers.get('X-Tenant-ID') && {
|
||||
'X-Tenant-ID': request.headers.get('X-Tenant-ID') as string,
|
||||
}),
|
||||
'X-Tenant-ID': request.headers.get('X-Tenant-ID') || DEFAULT_TENANT_ID,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -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 { AssessmentResultCard } from '@/components/sdk/use-case-assessment/AssessmentResultCard'
|
||||
import { OptimizerUpsellCard } from '@/components/sdk/compliance-optimizer/OptimizerUpsellCard'
|
||||
|
||||
interface Props {
|
||||
result: unknown
|
||||
@@ -35,6 +36,13 @@ export function ResultView({ result, onGoToAssessment, onGoToOverview }: Props)
|
||||
{r.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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,116 @@ export interface AdvisoryForm {
|
||||
custom_data_types: string[]
|
||||
purposes: 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_region: string
|
||||
model_usage: string[]
|
||||
|
||||
@@ -51,6 +51,71 @@ function AdvisoryBoardPageInner() {
|
||||
custom_data_types: [],
|
||||
purposes: [],
|
||||
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_region: '',
|
||||
model_usage: [],
|
||||
@@ -133,18 +198,164 @@ function AdvisoryBoardPageInner() {
|
||||
retention_purpose: form.retention_purpose,
|
||||
contracts_list: form.contracts,
|
||||
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,
|
||||
// Finance/Banking and General don't need separate context structs —
|
||||
// their fields are evaluated via existing FinancialContext or generic rules
|
||||
}
|
||||
|
||||
const url = isEditMode
|
||||
? `/api/sdk/v1/ucca/assessments/${editId}`
|
||||
: '/api/sdk/v1/ucca/assess'
|
||||
: '/api/sdk/v1/ucca/assess-enriched'
|
||||
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, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(intake),
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
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,214 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { ChecklistView } from './ChecklistView'
|
||||
|
||||
interface CheckItem {
|
||||
id: string
|
||||
label: string
|
||||
passed: boolean
|
||||
severity: string
|
||||
matched_text: string
|
||||
level?: number
|
||||
parent?: string | null
|
||||
skipped?: boolean
|
||||
hint?: string
|
||||
}
|
||||
|
||||
interface BannerResult {
|
||||
banner_detected: boolean
|
||||
banner_provider: string
|
||||
banner_checks?: {
|
||||
violations: { code: string; text: string; severity: string }[]
|
||||
has_impressum_link?: boolean
|
||||
has_dse_link?: boolean
|
||||
}
|
||||
structured_checks?: CheckItem[]
|
||||
completeness_pct?: number
|
||||
correctness_pct?: number
|
||||
phases?: {
|
||||
before_consent: { cookies: string[]; scripts: string[]; tracking_services: string[]; violations: any[] }
|
||||
after_reject: { cookies: string[]; scripts: string[]; new_tracking: string[]; violations: any[] }
|
||||
after_accept: { cookies: string[]; scripts: string[]; new_tracking: string[]; undocumented: string[] }
|
||||
}
|
||||
}
|
||||
|
||||
export function BannerCheckTab() {
|
||||
const [url, setUrl] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [progress, setProgress] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [result, setResult] = useState<BannerResult | null>(null)
|
||||
|
||||
const handleScan = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!url.trim()) return
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setResult(null)
|
||||
setProgress('Cookie-Banner wird analysiert...')
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/sdk/v1/agent/banner-check', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url: url.trim() }),
|
||||
})
|
||||
if (!res.ok) throw new Error(`Fehler: ${res.status}`)
|
||||
const data = await res.json()
|
||||
setResult(data)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Unbekannter Fehler')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setProgress('')
|
||||
}
|
||||
}
|
||||
|
||||
const structuredChecks = result?.structured_checks || []
|
||||
const hasStructured = structuredChecks.length > 0
|
||||
const compPct = result?.completeness_pct ?? 0
|
||||
const corrPct = result?.correctness_pct ?? 0
|
||||
|
||||
// Build ChecklistView-compatible result for structured checks
|
||||
const checklistResults = hasStructured ? [{
|
||||
label: `Cookie-Banner: ${result?.banner_provider || 'Unbekannt'}`,
|
||||
url: url,
|
||||
doc_type: 'banner',
|
||||
word_count: 0,
|
||||
completeness_pct: compPct,
|
||||
correctness_pct: corrPct,
|
||||
checks: structuredChecks,
|
||||
findings_count: structuredChecks.filter(c => !c.passed && !c.skipped).length,
|
||||
error: '',
|
||||
}] : []
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<h3 className="text-sm font-semibold text-blue-900">Cookie-Banner Compliance Check</h3>
|
||||
<p className="text-xs text-blue-700 mt-1">
|
||||
Playwright-basierter 3-Phasen-Test: Vor Interaktion, nach Ablehnen, nach Akzeptieren.
|
||||
Prueft Dark Patterns, Pre-Consent-Cookies, Farbkontrast, Klick-Paritaet und 36 weitere Kriterien.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleScan} className="flex gap-3">
|
||||
<input
|
||||
type="url" value={url} onChange={e => setUrl(e.target.value)}
|
||||
placeholder="https://www.example.com/"
|
||||
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={loading} required
|
||||
/>
|
||||
<button type="submit" disabled={loading || !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">
|
||||
{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...</>
|
||||
) : 'Banner pruefen'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{progress && (
|
||||
<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>
|
||||
{progress}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-sm text-red-700">{error}</div>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<div className="space-y-4">
|
||||
{/* 3-Phase Summary Card */}
|
||||
{result.phases && (
|
||||
<div className="bg-white border border-gray-200 rounded-xl shadow-sm overflow-hidden">
|
||||
<div className="px-6 py-4 bg-gray-50 border-b border-gray-200">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">
|
||||
{result.banner_detected ? '\u{1F6E1}\u{FE0F}' : '\u26A0\u{FE0F}'}
|
||||
</span>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900">
|
||||
{result.banner_detected
|
||||
? `Banner erkannt: ${result.banner_provider || 'Unbekannter Anbieter'}`
|
||||
: 'Kein Cookie-Banner erkannt'}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
3-Phasen-Analyse: Cookies und Scripts vor/nach Interaktion
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 py-3 grid grid-cols-3 gap-4">
|
||||
<PhaseBox
|
||||
label="Vor Consent"
|
||||
icon="\uD83D\uDD12"
|
||||
cookies={result.phases.before_consent.cookies?.length ?? 0}
|
||||
scripts={result.phases.before_consent.scripts?.length ?? 0}
|
||||
violations={result.phases.before_consent.violations?.length ?? 0}
|
||||
/>
|
||||
<PhaseBox
|
||||
label="Nach Ablehnen"
|
||||
icon="\uD83D\uDEAB"
|
||||
cookies={result.phases.after_reject.cookies?.length ?? 0}
|
||||
scripts={result.phases.after_reject.scripts?.length ?? 0}
|
||||
violations={result.phases.after_reject.violations?.length ?? 0}
|
||||
/>
|
||||
<PhaseBox
|
||||
label="Nach Akzeptieren"
|
||||
icon="\u2705"
|
||||
cookies={result.phases.after_accept.cookies?.length ?? 0}
|
||||
scripts={result.phases.after_accept.scripts?.length ?? 0}
|
||||
violations={0}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Structured L1/L2 Checklist */}
|
||||
{hasStructured && (
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm">
|
||||
<ChecklistView results={checklistResults} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!result.banner_detected && !hasStructured && (
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm">
|
||||
<p className="text-sm text-gray-500">
|
||||
Kein Cookie-Banner auf dieser Seite gefunden. Falls Cookies gesetzt werden, ist ein Banner nach ss25 TDDDG Pflicht.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PhaseBox({ label, icon, cookies, scripts, violations }: {
|
||||
label: string; icon: string; cookies: number; scripts: number; violations: number
|
||||
}) {
|
||||
return (
|
||||
<div className="text-center">
|
||||
<div className="text-lg">{icon}</div>
|
||||
<div className="text-xs font-medium text-gray-700">{label}</div>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
{cookies} Cookies, {scripts} Scripts
|
||||
</div>
|
||||
{violations > 0 && (
|
||||
<div className="text-xs text-red-600 font-medium">
|
||||
{violations} Verstoesse
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
'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',
|
||||
eu_institution: 'EU-Inst.', banner: 'Banner',
|
||||
}
|
||||
|
||||
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,128 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
|
||||
interface FAQItem {
|
||||
q: string
|
||||
a: string
|
||||
}
|
||||
|
||||
const FAQ_ITEMS: FAQItem[] = [
|
||||
{
|
||||
q: "Was passiert wenn ein Unternehmen wegen unzureichender Datenschutzerklaerung oder Cookie-Richtlinie verklagt wird?",
|
||||
a: `Es gibt vier Durchsetzungswege:
|
||||
|
||||
**1. Bussgelder durch Aufsichtsbehoerden (Art. 83 DSGVO)**
|
||||
Aufsichtsbehoerden pruefen von Amts wegen oder auf Beschwerde — kein Klaeger noetig. Bussgelder bis 20 Mio. EUR oder 4% des Jahresumsatzes. Beispiele: CNIL gegen Google (150 Mio. EUR), Facebook (60 Mio. EUR), H&M (35 Mio. EUR). Auch KMU sind betroffen — der LfDI Baden-Wuerttemberg hat Bussgelder ab 10.000 EUR verhaengt.
|
||||
|
||||
**2. Abmahnungen durch Verbraucherschutzverbaende**
|
||||
Verbaende wie vzbv oder DUH koennen ohne individuellen Schaden klagen (§2 UKlaG). Das ist der groesste praktische Druck: Unterlassungsklage + Anwaltskosten (5.000-20.000 EUR pro Fall). Seit EuGH C-319/20 (Meta/vzbv) duerfen Verbaende DSGVO-Verstoesse auch ohne Betroffenenauftrag klagen.
|
||||
|
||||
**3. Individueller Schadensersatz (Art. 82 DSGVO)**
|
||||
Seit EuGH C-300/21 (Oesterreichische Post) genuegt bereits der "Kontrollverlust" ueber Daten als immaterieller Schaden — kein messbarer finanzieller Schaden noetig. Typisch: 100-5.000 EUR pro Betroffenem. Legaltech-Firmen wie NOYB buendeln Massenverfahren.
|
||||
|
||||
**4. Wettbewerber-Abmahnungen (UWG)**
|
||||
Seit 2021 eingeschraenkt, aber Impressums-Maengel oder fehlende Cookie-Einwilligung bleiben abmahnfaehig.
|
||||
|
||||
Die Aufsichtsbehoerden erhalten ueber 10.000 Beschwerden pro Jahr. Eine Beschwerde einzureichen ist kostenlos und mit einem Klick moeglich.`,
|
||||
},
|
||||
{
|
||||
q: "Wie funktioniert die Dokumentenpruefung?",
|
||||
a: `Die Pruefung laeuft in drei Schritten:
|
||||
|
||||
**1. Text-Extraktion** — Playwright laedt die Seite, expandiert Accordions/Tabs und extrahiert den vollstaendigen Text.
|
||||
|
||||
**2. Regex-Checks (138 Pruefpunkte)** — Zwei Ebenen: L1 prueft ob Pflichtangaben erwaehnt sind (z.B. "Verantwortlicher"), L2 prueft ob sie korrekt und vollstaendig sind (z.B. "Hat der Verantwortliche eine ladungsfaehige Anschrift mit PLZ?").
|
||||
|
||||
**3. LLM-Verifikation** — Jeder fehlgeschlagene Check wird von einem KI-Modell (Qwen) gegen den Originaltext gegengeprueft, um Fehlalarme zu eliminieren.
|
||||
|
||||
Das Ergebnis: Zwei Scores pro Dokument — Vollstaendigkeit (sind alle Pflichtangaben da?) und Korrektheit (sind sie richtig formuliert?). Jeder fehlende Punkt hat eine konkrete Handlungsanweisung mit Rechtsbezug.`,
|
||||
},
|
||||
{
|
||||
q: "Welche Dokumenttypen werden geprueft?",
|
||||
a: `Sieben Dokumenttypen mit jeweils eigener Checkliste:
|
||||
|
||||
- **Datenschutzinformation (DSI)** — Art. 13/14 DSGVO (31 Checks)
|
||||
- **Cookie-Richtlinie** — §25 TDDDG (15 Checks)
|
||||
- **Impressum** — §5 TMG / §18 MStV (16 Checks)
|
||||
- **AGB** — §305ff BGB (21 Checks)
|
||||
- **Widerrufsbelehrung** — §355 BGB (15 Checks)
|
||||
- **Social Media DSE** — Art. 26 DSGVO Joint Controller (20 Checks)
|
||||
- **DSFA** — Art. 35 DSGVO (18 Checks)
|
||||
|
||||
Sub-Sektionen (z.B. Cookie-Abschnitt innerhalb der DSI) werden automatisch erkannt und separat geprueft.`,
|
||||
},
|
||||
{
|
||||
q: "Wie zuverlaessig sind die Ergebnisse?",
|
||||
a: `Die Pruefung wurde gegen mehrere Ground-Truth-Websites validiert (IHK Konstanz, ETO Gruppe, BMW, Stadt Koeln, Sparkasse, Spiegel u.a.). Ergebnis: **0 False Positives** bei validierten Testfaellen — jeder rote Punkt ist ein echtes Finding.
|
||||
|
||||
Durch die LLM-Verifikation werden Regex-Fehlalarme (z.B. durch ungewoehnliche Formatierung oder Soft Hyphens im HTML) automatisch korrigiert. Trotzdem gilt: Das Tool ersetzt keine Rechtsberatung, sondern identifiziert Handlungsbedarf.`,
|
||||
},
|
||||
{
|
||||
q: "Was kostet ein Verstoss gegen die DSGVO in der Praxis?",
|
||||
a: `Bussgelder nach Art. 83 DSGVO staffeln sich in zwei Stufen:
|
||||
|
||||
- **Bis 10 Mio. EUR / 2% Umsatz**: Verstoesse gegen technische/organisatorische Pflichten (Art. 25, 28, 32)
|
||||
- **Bis 20 Mio. EUR / 4% Umsatz**: Verstoesse gegen Grundsaetze, Betroffenenrechte, Drittlandtransfer
|
||||
|
||||
Typische Praxis-Bussgelder in Deutschland: 5.000-50.000 EUR fuer KMU, 100.000-1 Mio. EUR fuer groessere Unternehmen. Dazu kommen Anwaltskosten bei Abmahnungen (5.000-20.000 EUR pro Fall) und Reputationsschaden.`,
|
||||
},
|
||||
{
|
||||
q: "Was ist der aktuelle Stand bei harmonisierten Normen unter der neuen Maschinenverordnung (EU) 2023/1230?",
|
||||
a: `Die Maschinenverordnung (EU) 2023/1230 hat in Anhang I die wesentlichen Gesundheits- und Sicherheitsanforderungen und verweist darauf, dass harmonisierte Normen die technischen Details liefern sollen (Konformitaetsvermutung).
|
||||
|
||||
**Aktueller Stand:** Es gibt noch KEINE harmonisierten Normen die unter der neuen Maschinenverordnung im EU-Amtsblatt veroeffentlicht sind. Die bestehenden ~800 harmonisierten Normen gelten noch unter der alten Maschinenrichtlinie 2006/42/EC.
|
||||
|
||||
**Zeitplan:**
|
||||
- **Juni 2023** — Maschinenverordnung veroeffentlicht
|
||||
- **Januar 2025** — EU-Kommission hat Normungsauftrag an CEN/CENELEC erteilt
|
||||
- **Januar 2026** — CEN/CENELEC soll bestehende Normen bestaetigen oder Nachfolgenormen verabschieden
|
||||
- **Januar 2027** — Maschinenverordnung tritt vollstaendig in Kraft, ersetzt alte Richtlinie 2006/42/EC
|
||||
|
||||
**Wichtig fuer Hersteller:** Bis die neuen harmonisierten Normen veroeffentlicht sind, koennen Hersteller die bestehenden Normen der alten Maschinenrichtlinie weiterhin anwenden. Nach dem 20. Januar 2027 muessen Maschinen aber die Anforderungen der neuen Verordnung erfuellen — auch wenn die harmonisierten Normen bis dahin nicht vollstaendig vorliegen.
|
||||
|
||||
**IACE Normen-Bibliothek:** Die aktuelle Liste unter /sdk/iace/library enthaelt 751 harmonisierte Normen (1 A-Norm, 19 B1, 126 B2, 605 C-Normen). Diese muessen regelmaessig gegen das EU-Amtsblatt abgeglichen werden, da einige Normen zurueckgezogen oder ersetzt wurden.`,
|
||||
},
|
||||
]
|
||||
|
||||
export function ComplianceFAQ() {
|
||||
const [open, setOpen] = useState<number | null>(null)
|
||||
|
||||
return (
|
||||
<div className="border border-gray-200 rounded-xl overflow-hidden">
|
||||
<div className="px-4 py-3 bg-gray-50 border-b border-gray-200">
|
||||
<h3 className="text-sm font-semibold text-gray-800">Haeufige Fragen</h3>
|
||||
</div>
|
||||
<div className="divide-y divide-gray-100">
|
||||
{FAQ_ITEMS.map((item, i) => (
|
||||
<div key={i}>
|
||||
<button
|
||||
onClick={() => setOpen(open === i ? null : i)}
|
||||
className="w-full flex items-center justify-between px-4 py-3 text-left hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<span className="text-sm font-medium text-gray-900 pr-4">{item.q}</span>
|
||||
<svg
|
||||
className={`w-4 h-4 text-gray-400 shrink-0 transition-transform ${open === i ? '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>
|
||||
{open === i && (
|
||||
<div className="px-4 pb-4 text-sm text-gray-600 prose prose-sm max-w-none">
|
||||
{item.a.split('\n\n').map((para, pi) => (
|
||||
<p key={pi} className="mb-2 last:mb-0" dangerouslySetInnerHTML={{
|
||||
__html: para
|
||||
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\n- /g, '<br/>• ')
|
||||
.replace(/\n/g, '<br/>')
|
||||
}} />
|
||||
))}
|
||||
</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,319 @@
|
||||
'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'
|
||||
import { BannerCheckTab } from './_components/BannerCheckTab'
|
||||
import { ComplianceFAQ } from './_components/ComplianceFAQ'
|
||||
|
||||
type AnalysisMode = 'pre_launch' | 'post_launch'
|
||||
type AnalysisTab = 'quick' | 'scan' | 'doc-check' | 'banner-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' },
|
||||
{ id: 'banner-check', label: 'Banner-Check', desc: 'Cookie-Banner auf DSGVO-Konformitaet testen' },
|
||||
]
|
||||
|
||||
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 />}
|
||||
|
||||
{/* Banner Check Tab — own component */}
|
||||
{tab === 'banner-check' && <BannerCheckTab />}
|
||||
|
||||
{/* URL Input (quick + scan only) */}
|
||||
{(tab === 'quick' || tab === 'scan') && <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>
|
||||
)}
|
||||
{/* FAQ */}
|
||||
<ComplianceFAQ />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -8,9 +8,178 @@ import { LoadingSkeleton } from './_components/LoadingSkeleton'
|
||||
import { RiskPyramid } from './_components/RiskPyramid'
|
||||
import { AddSystemForm } from './_components/AddSystemForm'
|
||||
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() {
|
||||
const { state } = useSDK()
|
||||
const [activeTab, setActiveTab] = useState<TabId>('overview')
|
||||
const [systems, setSystems] = useState<AISystem[]>([])
|
||||
const [filter, setFilter] = useState<string>('all')
|
||||
const [showAddForm, setShowAddForm] = useState(false)
|
||||
@@ -178,17 +347,38 @@ export default function AIActPage() {
|
||||
explanation={stepInfo.explanation}
|
||||
tips={stepInfo.tips}
|
||||
>
|
||||
<button
|
||||
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>
|
||||
KI-System registrieren
|
||||
</button>
|
||||
{activeTab === 'overview' && (
|
||||
<button
|
||||
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>
|
||||
KI-System registrieren
|
||||
</button>
|
||||
)}
|
||||
</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 && (
|
||||
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center justify-between">
|
||||
<span>{error}</span>
|
||||
@@ -196,82 +386,105 @@ export default function AIActPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAddForm && (
|
||||
<AddSystemForm
|
||||
onSubmit={handleAddSystem}
|
||||
onCancel={() => { setShowAddForm(false); setEditingSystem(null) }}
|
||||
initialData={editingSystem}
|
||||
/>
|
||||
)}
|
||||
|
||||
<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>
|
||||
|
||||
<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}
|
||||
{/* Tab: Overview */}
|
||||
{activeTab === 'overview' && (
|
||||
<>
|
||||
{/* Add/Edit System Form */}
|
||||
{showAddForm && (
|
||||
<AddSystemForm
|
||||
onSubmit={handleAddSystem}
|
||||
onCancel={() => { setShowAddForm(false); setEditingSystem(null) }}
|
||||
initialData={editingSystem}
|
||||
/>
|
||||
))}
|
||||
</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 && (
|
||||
<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>
|
||||
{/* Tab: Decision Tree */}
|
||||
{activeTab === 'decision-tree' && (
|
||||
<DecisionTreeWizard />
|
||||
)}
|
||||
|
||||
{/* Tab: Results */}
|
||||
{activeTab === 'results' && (
|
||||
<SavedResultsTab />
|
||||
)}
|
||||
</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'],
|
||||
},
|
||||
{
|
||||
id: 'document-crawler',
|
||||
name: 'Document Crawler',
|
||||
nameShort: 'Crawler',
|
||||
id: 'control-pipeline',
|
||||
name: 'Control Pipeline',
|
||||
nameShort: 'Pipeline',
|
||||
layer: 'backend',
|
||||
tech: 'Python / FastAPI',
|
||||
port: 8098,
|
||||
url: 'https://macmini:8098',
|
||||
container: 'bp-compliance-document-crawler',
|
||||
description: 'Dokument-Analyse (PDF, DOCX, XLSX, PPTX), Gap-Analyse, IPFS-Archivierung.',
|
||||
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.',
|
||||
dbTables: [],
|
||||
ragCollections: [],
|
||||
apiEndpoints: [
|
||||
'POST /analyze',
|
||||
'POST /gap-analysis',
|
||||
'POST /archive',
|
||||
container: 'bp-core-control-pipeline',
|
||||
description: 'RAG-zu-Controls Pipeline: Control Generation, Pass 0a/0b, Ontology, Dedup, Dependency Engine, Applicability.',
|
||||
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: [
|
||||
'canonical_controls', 'obligation_candidates', 'control_parent_links',
|
||||
'control_dependencies', 'control_evaluation_results',
|
||||
'canonical_processed_chunks', 'canonical_generation_jobs',
|
||||
'control_dedup_reviews', 'control_patterns',
|
||||
],
|
||||
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',
|
||||
@@ -383,7 +398,7 @@ export const ARCH_EDGES: ArchEdge[] = [
|
||||
// Frontend → Backend
|
||||
{ source: 'admin-compliance', target: 'backend-compliance', 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
|
||||
{ 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: 'postgresql', label: 'GORM' },
|
||||
{ source: 'compliance-tts', target: 'minio', label: 'Audio/Video' },
|
||||
|
||||
// Backend → Backend
|
||||
{ source: 'document-crawler', target: 'ai-compliance-sdk', label: 'LLM Gateway' },
|
||||
|
||||
// Backend → Data Sovereignty
|
||||
{ source: 'document-crawler', target: 'dsms', label: 'IPFS Archive' },
|
||||
{ source: 'control-pipeline', target: 'postgresql', label: 'SQLAlchemy' },
|
||||
{ source: 'control-pipeline', target: 'qdrant', label: 'Embedding + Dedup' },
|
||||
{ source: 'control-pipeline', target: 'ollama', label: 'LLM Dedup (qwen3.5)' },
|
||||
]
|
||||
|
||||
// =============================================================================
|
||||
|
||||
@@ -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'
|
||||
|
||||
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({
|
||||
loading,
|
||||
documents,
|
||||
versions,
|
||||
selectedDocument,
|
||||
setSelectedDocument,
|
||||
loading, documents, versions, selectedDocument, setSelectedDocument,
|
||||
onSubmitReview, onApprove, onReject, onPublish,
|
||||
}: {
|
||||
loading: boolean
|
||||
documents: Document[]
|
||||
versions: Version[]
|
||||
selectedDocument: string
|
||||
setSelectedDocument: (id: string) => void
|
||||
onSubmitReview?: (versionId: string) => void
|
||||
onApprove?: (versionId: string) => void
|
||||
onReject?: (versionId: string, comment: string) => void
|
||||
onPublish?: (versionId: string) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
@@ -27,73 +37,69 @@ export function VersionsTab({
|
||||
>
|
||||
<option value="">Dokument auswaehlen...</option>
|
||||
{documents.map((doc) => (
|
||||
<option key={doc.id} value={doc.id}>
|
||||
{doc.name}
|
||||
</option>
|
||||
<option key={doc.id} value={doc.id}>{doc.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{selectedDocument && (
|
||||
<button className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors text-sm font-medium">
|
||||
+ Neue Version
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!selectedDocument ? (
|
||||
<div className="text-center py-12 text-slate-500">
|
||||
Bitte waehlen Sie ein Dokument aus
|
||||
</div>
|
||||
<div className="text-center py-12 text-slate-500">Bitte waehlen Sie ein Dokument aus</div>
|
||||
) : loading ? (
|
||||
<div className="text-center py-12 text-slate-500">Lade Versionen...</div>
|
||||
) : versions.length === 0 ? (
|
||||
<div className="text-center py-12 text-slate-500">
|
||||
Keine Versionen vorhanden
|
||||
</div>
|
||||
<div className="text-center py-12 text-slate-500">Keine Versionen vorhanden</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{versions.map((version) => (
|
||||
<div
|
||||
key={version.id}
|
||||
className="border border-slate-200 rounded-lg p-4 hover:border-purple-300 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-semibold text-slate-900">v{version.version}</span>
|
||||
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 rounded text-xs">
|
||||
{version.language.toUpperCase()}
|
||||
</span>
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded text-xs ${
|
||||
version.status === 'published'
|
||||
? 'bg-green-100 text-green-700'
|
||||
: version.status === 'draft'
|
||||
? 'bg-yellow-100 text-yellow-700'
|
||||
: 'bg-slate-100 text-slate-600'
|
||||
}`}
|
||||
>
|
||||
{version.status}
|
||||
</span>
|
||||
{versions.map((version) => {
|
||||
const style = STATUS_STYLES[version.status] || STATUS_STYLES.draft
|
||||
return (
|
||||
<div key={version.id} className="border border-slate-200 rounded-lg p-4 hover:border-purple-300 transition-colors">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-semibold text-slate-900">v{version.version}</span>
|
||||
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 rounded text-xs">
|
||||
{version.language.toUpperCase()}
|
||||
</span>
|
||||
<span className={`px-2 py-0.5 rounded text-xs ${style.color}`}>{style.label}</span>
|
||||
</div>
|
||||
<h3 className="text-slate-700">{version.title}</h3>
|
||||
<p className="text-sm text-slate-500 mt-1">
|
||||
Erstellt: {new Date(version.created_at).toLocaleDateString('de-DE')}
|
||||
{version.published_at && ` | Publiziert: ${new Date(version.published_at).toLocaleDateString('de-DE')}`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-wrap justify-end">
|
||||
{version.status === 'draft' && onSubmitReview && (
|
||||
<button onClick={() => onSubmitReview(version.id)}
|
||||
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>
|
||||
<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>
|
||||
|
||||
@@ -277,6 +277,45 @@ export function useConsentData(activeTab: Tab, selectedDocument: string) {
|
||||
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 {
|
||||
documents, versions, loading, error, setError,
|
||||
consentStats, dsrCounts, dsrOverview,
|
||||
@@ -286,6 +325,7 @@ export function useConsentData(activeTab: Tab, selectedDocument: string) {
|
||||
savingTemplateId, savingProcessId,
|
||||
saveApiEmailTemplate, saveApiGdprProcess,
|
||||
loadApiEmailTemplates,
|
||||
submitVersionForReview, approveVersion, rejectVersion, publishVersion,
|
||||
authToken, setAuthToken,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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 {
|
||||
id: string
|
||||
|
||||
@@ -25,6 +25,8 @@ import { GdprTab } from './_components/GdprTab'
|
||||
import { StatsTab } from './_components/StatsTab'
|
||||
import { ConsentTemplateCreateModal } from './_components/ConsentTemplateCreateModal'
|
||||
import { EmailTemplateEditModal, EmailTemplatePreviewModal } from './_components/EmailTemplateModals'
|
||||
import { DeadlineTab } from './_components/DeadlineTab'
|
||||
import { IntegrationStubs } from './_components/IntegrationStubs'
|
||||
|
||||
export default function ConsentManagementPage() {
|
||||
const { state } = useSDK()
|
||||
@@ -45,6 +47,7 @@ export default function ConsentManagementPage() {
|
||||
savingTemplateId, savingProcessId,
|
||||
saveApiEmailTemplate, saveApiGdprProcess,
|
||||
loadApiEmailTemplates,
|
||||
submitVersionForReview, approveVersion, rejectVersion, publishVersion,
|
||||
authToken, setAuthToken,
|
||||
} = useConsentData(activeTab, selectedDocument)
|
||||
|
||||
@@ -54,6 +57,8 @@ export default function ConsentManagementPage() {
|
||||
{ id: 'emails', label: 'E-Mail Vorlagen' },
|
||||
{ id: 'gdpr', label: 'DSGVO Prozesse' },
|
||||
{ id: 'stats', label: 'Statistiken' },
|
||||
{ id: 'deadlines', label: 'Fristen' },
|
||||
{ id: 'integrations', label: 'Integrationen' },
|
||||
]
|
||||
|
||||
return (
|
||||
@@ -128,6 +133,10 @@ export default function ConsentManagementPage() {
|
||||
versions={versions}
|
||||
selectedDocument={selectedDocument}
|
||||
setSelectedDocument={setSelectedDocument}
|
||||
onSubmitReview={submitVersionForReview}
|
||||
onApprove={approveVersion}
|
||||
onReject={rejectVersion}
|
||||
onPublish={publishVersion}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -157,6 +166,10 @@ export default function ConsentManagementPage() {
|
||||
)}
|
||||
|
||||
{activeTab === 'stats' && <StatsTab consentStats={consentStats} />}
|
||||
|
||||
{activeTab === 'deadlines' && <DeadlineTab />}
|
||||
|
||||
{activeTab === 'integrations' && <IntegrationStubs />}
|
||||
</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: 'misc', label: 'Weitere', types: ['community_guidelines', 'copyright_policy', 'data_usage_clause'] },
|
||||
{ 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() {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-8 text-center">
|
||||
<div className="w-16 h-16 mx-auto bg-gray-100 rounded-full flex items-center justify-center mb-4">
|
||||
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<SettingsTabContent />
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<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>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
label: 'Cookie-Banner',
|
||||
href: '/sdk/einwilligungen/cookie-banner',
|
||||
href: '/sdk/cookie-banner',
|
||||
icon: Cookie,
|
||||
description: 'Cookie-Consent konfigurieren',
|
||||
},
|
||||
|
||||
@@ -130,7 +130,7 @@ function CatalogContent() {
|
||||
</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"
|
||||
>
|
||||
<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'
|
||||
|
||||
/**
|
||||
* Cookie Banner Configuration Page
|
||||
*
|
||||
* 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>
|
||||
)
|
||||
export default function CookieBannerRedirect() {
|
||||
redirect('/sdk/cookie-banner')
|
||||
}
|
||||
|
||||
@@ -15,11 +15,16 @@ interface EditorTabProps {
|
||||
onPublish: () => void
|
||||
onPreview: () => void
|
||||
onBack: () => void
|
||||
onSubmitForReview?: () => void
|
||||
onApprove?: (comment?: string) => void
|
||||
onReject?: (comment: string) => void
|
||||
onSendTest?: (email: string) => void
|
||||
}
|
||||
|
||||
export function EditorTab({
|
||||
template, version, subject, html, previewHtml, saving,
|
||||
onSubjectChange, onHtmlChange, onSave, onPublish, onPreview, onBack,
|
||||
onSubmitForReview, onApprove, onReject, onSendTest,
|
||||
}: EditorTabProps) {
|
||||
if (!template) {
|
||||
return (
|
||||
@@ -46,30 +51,56 @@ export function EditorTab({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<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"
|
||||
>
|
||||
{saving ? 'Speichern...' : 'Version speichern'}
|
||||
</button>
|
||||
{version && version.status !== 'published' && (
|
||||
<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"
|
||||
>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{/* Save — always available for draft/review */}
|
||||
{(!version || version.status === 'draft' || version.status === 'review') && (
|
||||
<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">
|
||||
{saving ? 'Speichern...' : 'Version speichern'}
|
||||
</button>
|
||||
)}
|
||||
{/* Submit for Review — only for draft */}
|
||||
{version && version.status === 'draft' && onSubmitForReview && (
|
||||
<button onClick={onSubmitForReview} disabled={saving}
|
||||
className="px-3 py-1.5 bg-yellow-500 text-white rounded-lg text-sm hover:bg-yellow-600 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
|
||||
</button>
|
||||
)}
|
||||
{/* Preview + Test — always when version exists */}
|
||||
{version && (
|
||||
<button
|
||||
onClick={onPreview}
|
||||
className="px-3 py-1.5 border border-gray-300 text-gray-700 rounded-lg text-sm hover:bg-gray-50"
|
||||
>
|
||||
Vorschau
|
||||
</button>
|
||||
<>
|
||||
<button onClick={onPreview}
|
||||
className="px-3 py-1.5 border border-gray-300 text-gray-700 rounded-lg text-sm hover:bg-gray-50">
|
||||
Vorschau
|
||||
</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>
|
||||
@@ -77,7 +108,7 @@ export function EditorTab({
|
||||
{/* Variables */}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<span className="text-xs text-gray-500 mr-1">Variablen:</span>
|
||||
{(template.variables || []).map(v => (
|
||||
{(Array.isArray(template.variables) ? template.variables : []).map(v => (
|
||||
<button
|
||||
key={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>
|
||||
)}
|
||||
<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">
|
||||
{`{{${v}}}`}
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
SendLog,
|
||||
Settings,
|
||||
TabId,
|
||||
TemplateApproval,
|
||||
TemplateType,
|
||||
TemplateVersion,
|
||||
getHeaders,
|
||||
@@ -194,6 +195,72 @@ export function useEmailTemplates(activeTab: TabId) {
|
||||
}
|
||||
}, [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 () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/initialize`, {
|
||||
@@ -222,6 +289,8 @@ export function useEmailTemplates(activeTab: TabId) {
|
||||
setSettingsForm,
|
||||
// Actions
|
||||
openEditor, saveVersion, publishVersion, loadPreview,
|
||||
submitForReview, approveVersion, rejectVersion,
|
||||
sendTestEmail, loadApprovalHistory,
|
||||
saveSettings2, initializeDefaults,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,16 @@ export interface SendLog {
|
||||
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 {
|
||||
sender_name: string
|
||||
sender_email: string
|
||||
|
||||
@@ -22,6 +22,8 @@ export default function EmailTemplatesPage() {
|
||||
setEditorSubject, setEditorHtml,
|
||||
setSettingsForm,
|
||||
openEditor, saveVersion, publishVersion, loadPreview,
|
||||
submitForReview, approveVersion, rejectVersion,
|
||||
sendTestEmail, loadApprovalHistory,
|
||||
saveSettings2, initializeDefaults,
|
||||
} = useEmailTemplates(activeTab)
|
||||
|
||||
@@ -68,6 +70,10 @@ export default function EmailTemplatesPage() {
|
||||
onPublish={publishVersion}
|
||||
onPreview={loadPreview}
|
||||
onBack={() => setActiveTab('templates')}
|
||||
onSubmitForReview={submitForReview}
|
||||
onApprove={approveVersion}
|
||||
onReject={rejectVersion}
|
||||
onSendTest={sendTestEmail}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
|
||||
interface ComplianceTrigger {
|
||||
id: string
|
||||
regulation: string
|
||||
article: string
|
||||
title: string
|
||||
severity: 'high' | 'medium' | 'low'
|
||||
reason: string
|
||||
affected_hazard_count?: number
|
||||
module_path: string
|
||||
module_label: string
|
||||
}
|
||||
|
||||
interface TriggersResponse {
|
||||
triggers: ComplianceTrigger[]
|
||||
total: number
|
||||
}
|
||||
|
||||
const SEVERITY_CONFIG: Record<string, { border: string; bg: string; text: string; badge: string; icon: string }> = {
|
||||
high: {
|
||||
border: 'border-red-200 dark:border-red-800',
|
||||
bg: 'bg-red-50 dark:bg-red-900/20',
|
||||
text: 'text-red-700 dark:text-red-400',
|
||||
badge: 'bg-red-100 text-red-800 dark:bg-red-900/50 dark:text-red-300',
|
||||
icon: 'text-red-500',
|
||||
},
|
||||
medium: {
|
||||
border: 'border-yellow-200 dark:border-yellow-800',
|
||||
bg: 'bg-yellow-50 dark:bg-yellow-900/20',
|
||||
text: 'text-yellow-700 dark:text-yellow-400',
|
||||
badge: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/50 dark:text-yellow-300',
|
||||
icon: 'text-yellow-500',
|
||||
},
|
||||
low: {
|
||||
border: 'border-blue-200 dark:border-blue-800',
|
||||
bg: 'bg-blue-50 dark:bg-blue-900/20',
|
||||
text: 'text-blue-700 dark:text-blue-400',
|
||||
badge: 'bg-blue-100 text-blue-800 dark:bg-blue-900/50 dark:text-blue-300',
|
||||
icon: 'text-blue-500',
|
||||
},
|
||||
}
|
||||
|
||||
const SEVERITY_LABELS: Record<string, string> = {
|
||||
high: 'HOCH',
|
||||
medium: 'MITTEL',
|
||||
low: 'NIEDRIG',
|
||||
}
|
||||
|
||||
const REGULATION_BADGES: { key: string; label: string; activeColor: string }[] = [
|
||||
{ key: 'DSGVO', label: 'DSGVO', activeColor: 'bg-red-100 text-red-800 border-red-300' },
|
||||
{ key: 'AI Act', label: 'AI Act', activeColor: 'bg-orange-100 text-orange-800 border-orange-300' },
|
||||
{ key: 'CRA', label: 'CRA', activeColor: 'bg-yellow-100 text-yellow-800 border-yellow-300' },
|
||||
{ key: 'NIS2', label: 'NIS2', activeColor: 'bg-indigo-100 text-indigo-800 border-indigo-300' },
|
||||
{ key: 'Data Act', label: 'Data Act', activeColor: 'bg-amber-100 text-amber-800 border-amber-300' },
|
||||
]
|
||||
|
||||
function WarningIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4.5c-.77-.833-2.694-.833-3.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function ChevronIcon({ open }: { open: boolean }) {
|
||||
return (
|
||||
<svg className={`w-4 h-4 text-gray-400 transition-transform ${open ? '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>
|
||||
)
|
||||
}
|
||||
|
||||
export function ComplianceAlerts({ projectId }: { projectId: string }) {
|
||||
const [data, setData] = useState<TriggersResponse | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set())
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/sdk/v1/iace/projects/${projectId}/compliance-triggers`)
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((json) => {
|
||||
if (!json) return
|
||||
// Map API format (nested trigger object) to flat frontend format
|
||||
const raw = json.triggers || []
|
||||
const mapped: ComplianceTrigger[] = raw.map((t: Record<string, unknown>, i: number) => {
|
||||
const inner = (t.trigger || t) as Record<string, unknown>
|
||||
const reg = (inner.regulation || '') as string
|
||||
return {
|
||||
id: (t.hazard_id as string) || `trigger-${i}`,
|
||||
regulation: reg.split(' ')[0] || reg,
|
||||
article: reg.includes(' ') ? reg.split(' ').slice(1).join(' ') : '',
|
||||
title: (inner.action_de || inner.trigger_cond_de || '') as string,
|
||||
severity: ((inner.severity || 'medium') as string) as 'high' | 'medium' | 'low',
|
||||
reason: (inner.trigger_cond_de || '') as string,
|
||||
affected_hazard_count: 1,
|
||||
module_path: (inner.module_link || '/sdk') as string,
|
||||
module_label: ((inner.module || 'Modul') as string).toUpperCase(),
|
||||
}
|
||||
})
|
||||
setData({ triggers: mapped, total: mapped.length })
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [projectId])
|
||||
|
||||
if (loading) return null
|
||||
if (!data || data.triggers.length === 0) return null
|
||||
|
||||
const triggers = data.triggers
|
||||
const activeRegulations = new Set(triggers.map((t) => t.regulation))
|
||||
|
||||
function toggleExpanded(id: string) {
|
||||
setExpandedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-red-200 dark:border-red-800">
|
||||
{/* Header */}
|
||||
<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-red-50 dark:bg-red-900/30 rounded-lg flex items-center justify-center">
|
||||
<WarningIcon className="w-5 h-5 text-red-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{triggers.length} Compliance-Hinweise erkannt
|
||||
</h2>
|
||||
<p className="text-xs text-gray-500">
|
||||
Basierend auf den identifizierten Gefaehrdungen bestehen rechtliche Implikationen
|
||||
</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">
|
||||
<ChevronIcon open={!collapsed} />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{!collapsed && (
|
||||
<div className="px-6 pb-6 space-y-4">
|
||||
{/* Regulation summary badges */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{REGULATION_BADGES.map((reg) => {
|
||||
const active = activeRegulations.has(reg.key)
|
||||
return (
|
||||
<span
|
||||
key={reg.key}
|
||||
className={`px-2.5 py-1 text-xs font-medium rounded-full border ${
|
||||
active
|
||||
? reg.activeColor
|
||||
: 'bg-gray-50 text-gray-400 border-gray-200 dark:bg-gray-700 dark:text-gray-500 dark:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
{reg.label}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Trigger list */}
|
||||
<div className="space-y-2">
|
||||
{triggers.map((trigger) => {
|
||||
const sev = SEVERITY_CONFIG[trigger.severity] || SEVERITY_CONFIG.low
|
||||
const isOpen = expandedIds.has(trigger.id)
|
||||
|
||||
return (
|
||||
<div key={trigger.id} className={`rounded-lg border ${sev.border} ${sev.bg} overflow-hidden`}>
|
||||
{/* Trigger header row */}
|
||||
<button
|
||||
onClick={() => toggleExpanded(trigger.id)}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 text-left"
|
||||
>
|
||||
<ChevronIcon open={isOpen} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{trigger.regulation} {trigger.article} — {trigger.title}
|
||||
</span>
|
||||
</div>
|
||||
<span className={`px-2 py-0.5 text-xs font-bold rounded ${sev.badge}`}>
|
||||
{SEVERITY_LABELS[trigger.severity] || trigger.severity}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Expanded detail */}
|
||||
{isOpen && (
|
||||
<div className="px-4 pb-4 pt-0 ml-7 space-y-2">
|
||||
<p className="text-xs text-gray-700 dark:text-gray-300">
|
||||
<span className="font-medium">Grund:</span> {trigger.reason}
|
||||
</p>
|
||||
{trigger.affected_hazard_count != null && trigger.affected_hazard_count > 0 && (
|
||||
<p className="text-xs text-gray-500">
|
||||
Betroffene Gefaehrdungen: {trigger.affected_hazard_count}
|
||||
</p>
|
||||
)}
|
||||
<Link
|
||||
href={trigger.module_path}
|
||||
className={`inline-flex items-center gap-1.5 text-xs font-medium ${sev.text} hover:underline`}
|
||||
>
|
||||
{trigger.module_label} oeffnen
|
||||
<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="M13 7l5 5m0 0l-5 5m5-5H6" />
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Disclaimer */}
|
||||
<div className="p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 text-xs text-amber-800 dark:text-amber-300">
|
||||
<strong>Hinweis:</strong> Diese Compliance-Hinweise werden automatisch aus den
|
||||
Gefaehrdungen und Klassifikationen abgeleitet. Der CE-Fachmann muss die
|
||||
regulatorischen Anforderungen im jeweiligen Modul verifizieren.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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.
|
||||
</p>
|
||||
</div>
|
||||
{!c.showForm && (
|
||||
{!showForm && (
|
||||
<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">
|
||||
<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" />
|
||||
@@ -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="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">
|
||||
@@ -108,14 +108,14 @@ export default function ComponentsPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="py-1">
|
||||
{c.tree.map((component) => (
|
||||
{tree.map((component) => (
|
||||
<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>
|
||||
) : (
|
||||
!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="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">
|
||||
@@ -128,11 +128,11 @@ export default function ComponentsPage() {
|
||||
Erstellen Sie eine hierarchische Struktur aus Software, Firmware, KI-Modulen und Hardware.
|
||||
</p>
|
||||
<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">
|
||||
Aus Bibliothek waehlen
|
||||
</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">
|
||||
Manuell hinzufuegen
|
||||
</button>
|
||||
|
||||
@@ -11,13 +11,13 @@ export function AutoSuggestPanel({ matchResult, applying, onApply, onClose }: {
|
||||
onClose: () => void
|
||||
}) {
|
||||
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>>(
|
||||
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>>(
|
||||
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) {
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
HazardFormData, HAZARD_CATEGORIES, CATEGORY_LABELS, getRiskColor, getRiskLevelISO, RoleInfo,
|
||||
} from './types'
|
||||
import { RiskBadge } from './RiskBadge'
|
||||
|
||||
interface CustomHazardModalProps {
|
||||
onSubmit: (data: HazardFormData) => void
|
||||
onClose: () => void
|
||||
roles: RoleInfo[]
|
||||
}
|
||||
|
||||
const INITIAL_FORM: HazardFormData = {
|
||||
name: '', description: '', category: 'mechanical', component_id: '',
|
||||
severity: 3, exposure: 3, probability: 3, avoidance: 3,
|
||||
lifecycle_phase: '', trigger_event: '', affected_person: '',
|
||||
possible_harm: '', hazardous_zone: '', machine_module: '',
|
||||
}
|
||||
|
||||
export function CustomHazardModal({ onSubmit, onClose, roles }: CustomHazardModalProps) {
|
||||
const [form, setForm] = useState<HazardFormData>(INITIAL_FORM)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const rInherent = form.severity * form.exposure * form.probability * form.avoidance
|
||||
const riskLevel = getRiskLevelISO(rInherent)
|
||||
|
||||
function set<K extends keyof HazardFormData>(key: K, val: HazardFormData[K]) {
|
||||
setForm(prev => ({ ...prev, [key]: val }))
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!form.name.trim()) return
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await onSubmit(form)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const inputCls = 'w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white text-sm'
|
||||
const labelCls = 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1'
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
|
||||
<div className="relative bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full max-w-2xl max-h-[90vh] overflow-y-auto mx-4">
|
||||
<div className="sticky top-0 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-6 py-4 flex items-center justify-between rounded-t-xl z-10">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Eigene Gefaehrdung erstellen</h2>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
Maschinenspezifische Gefaehrdung definieren, die nicht in der Bibliothek enthalten ist.
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-5 space-y-5">
|
||||
{/* Name + Category */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelCls}>Bezeichnung (DE) *</label>
|
||||
<input type="text" value={form.name} onChange={e => set('name', e.target.value)}
|
||||
placeholder="z.B. Quetschung durch Sondergreifer" className={inputCls} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Kategorie *</label>
|
||||
<select value={form.category} onChange={e => set('category', e.target.value)} className={inputCls}>
|
||||
{HAZARD_CATEGORIES.map(cat => (
|
||||
<option key={cat} value={cat}>{CATEGORY_LABELS[cat] || cat}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scenario */}
|
||||
<div>
|
||||
<label className={labelCls}>Gefahrensituation / Beschreibung</label>
|
||||
<textarea value={form.description} onChange={e => set('description', e.target.value)}
|
||||
rows={2} placeholder="Beschreibung der Gefahrensituation..."
|
||||
className={inputCls} />
|
||||
</div>
|
||||
|
||||
{/* Trigger + Harm */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelCls}>Ausloeseereignis</label>
|
||||
<input type="text" value={form.trigger_event} onChange={e => set('trigger_event', e.target.value)}
|
||||
placeholder="z.B. Schutztuer offen bei Betrieb" className={inputCls} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Moeglicher Schaden</label>
|
||||
<input type="text" value={form.possible_harm} onChange={e => set('possible_harm', e.target.value)}
|
||||
placeholder="z.B. Schwere Quetschverletzung" className={inputCls} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Affected + Zone */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelCls}>Betroffene Personen</label>
|
||||
{roles.length > 0 ? (
|
||||
<select value={form.affected_person} onChange={e => set('affected_person', e.target.value)} className={inputCls}>
|
||||
<option value="">-- Bitte waehlen --</option>
|
||||
{roles.map(r => <option key={r.id} value={r.id}>{r.label_de}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
<input type="text" value={form.affected_person} onChange={e => set('affected_person', e.target.value)}
|
||||
placeholder="z.B. Bediener, Wartungspersonal" className={inputCls} />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Gefahrenzone</label>
|
||||
<input type="text" value={form.hazardous_zone} onChange={e => set('hazardous_zone', e.target.value)}
|
||||
placeholder="z.B. Greifer-Arbeitsbereich" className={inputCls} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Machine module */}
|
||||
<div>
|
||||
<label className={labelCls}>Maschinenmodul</label>
|
||||
<input type="text" value={form.machine_module} onChange={e => set('machine_module', e.target.value)}
|
||||
placeholder="z.B. Sondergreifer Typ X" className={inputCls} />
|
||||
</div>
|
||||
|
||||
{/* Risk sliders */}
|
||||
<div className="bg-gray-50 dark:bg-gray-750 rounded-lg p-4">
|
||||
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
Standard-Risikobewertung (R = S x F x P x A)
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{([
|
||||
{ label: 'Schwere (S)', key: 'severity' as const, low: 'Gering', high: 'Toedlich' },
|
||||
{ label: 'Haeufigkeit (F)', key: 'exposure' as const, low: 'Selten', high: 'Staendig' },
|
||||
{ label: 'Wahrscheinl. (P)', key: 'probability' as const, low: 'Unwahrsch.', high: 'Sehr wahrsch.' },
|
||||
{ label: 'Vermeidbarkeit (A)', key: 'avoidance' as const, low: 'Leicht', high: 'Unmoeglich' },
|
||||
]).map(({ label, key, low, high }) => (
|
||||
<div key={key}>
|
||||
<label className="block text-xs text-gray-600 dark:text-gray-400 mb-1">
|
||||
{label}: <span className="font-bold">{form[key]}</span>
|
||||
</label>
|
||||
<input type="range" min={1} max={5} value={form[key]}
|
||||
onChange={e => set(key, Number(e.target.value))}
|
||||
className="w-full accent-purple-600" />
|
||||
<div className="flex justify-between text-[10px] text-gray-400">
|
||||
<span>{low}</span><span>{high}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className={`mt-3 p-2 rounded-lg border ${getRiskColor(riskLevel)}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium">R = {form.severity} x {form.exposure} x {form.probability} x {form.avoidance}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-bold">{rInherent}</span>
|
||||
<RiskBadge level={riskLevel} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tags hint */}
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-3">
|
||||
<p className="text-xs text-blue-700 dark:text-blue-300">
|
||||
Die Gefaehrdung wird direkt in das Projekt-Hazard-Log aufgenommen.
|
||||
Sie koennen die Risikobewertung anschliessend in der Risikomatrix anpassen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="sticky bottom-0 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 px-6 py-4 flex items-center justify-end gap-3 rounded-b-xl">
|
||||
<button onClick={onClose}
|
||||
className="px-4 py-2 text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors">
|
||||
Abbrechen
|
||||
</button>
|
||||
<button onClick={handleSave} disabled={!form.name.trim() || submitting}
|
||||
className={`px-5 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||
form.name.trim() && !submitting
|
||||
? 'bg-purple-600 text-white hover:bg-purple-700'
|
||||
: 'bg-gray-200 text-gray-400 cursor-not-allowed dark:bg-gray-700 dark:text-gray-500'
|
||||
}`}>
|
||||
{submitting ? 'Wird erstellt...' : 'Gefaehrdung erstellen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { Hazard } from './types'
|
||||
|
||||
export type ResidualFilter = 'all' | 'open' | 'acceptable' | 'not_acceptable'
|
||||
|
||||
interface ResidualRiskPanelProps {
|
||||
hazards: Hazard[]
|
||||
/** Explicit accept/reject decisions keyed by hazard ID. */
|
||||
decisions: Record<string, boolean | null>
|
||||
activeFilter: ResidualFilter
|
||||
onFilterChange: (f: ResidualFilter) => void
|
||||
}
|
||||
|
||||
// RPZ thresholds matching RiskAssessmentTable logic
|
||||
function rpz(h: Hazard): number {
|
||||
return h.r_inherent || h.severity * h.exposure * h.probability * (h.avoidance >= 1 ? h.avoidance : 1)
|
||||
}
|
||||
|
||||
type ResidualStatus = 'acceptable' | 'not_acceptable' | 'open'
|
||||
|
||||
export function getResidualStatus(h: Hazard, decision: boolean | null | undefined): ResidualStatus {
|
||||
if (decision === true) return 'acceptable'
|
||||
if (decision === false) return 'not_acceptable'
|
||||
// No explicit decision -- derive from RPZ
|
||||
const r = rpz(h)
|
||||
if (r <= 20) return 'acceptable'
|
||||
if (r <= 60) return 'open' // conditional -- needs explicit decision
|
||||
return 'not_acceptable'
|
||||
}
|
||||
|
||||
export function ResidualRiskPanel({ hazards, decisions, activeFilter, onFilterChange }: ResidualRiskPanelProps) {
|
||||
const stats = useMemo(() => {
|
||||
let assessed = 0, acceptable = 0, open = 0
|
||||
for (const h of hazards) {
|
||||
const status = getResidualStatus(h, decisions[h.id] ?? null)
|
||||
if (status === 'acceptable') { assessed++; acceptable++ }
|
||||
else if (status === 'not_acceptable') { assessed++ }
|
||||
else { open++ }
|
||||
}
|
||||
return { total: hazards.length, assessed, acceptable, open, notAcceptable: assessed - acceptable }
|
||||
}, [hazards, decisions])
|
||||
|
||||
const pct = stats.total > 0 ? Math.round((stats.assessed / stats.total) * 100) : 0
|
||||
|
||||
const filters: { key: ResidualFilter; label: string; count: number }[] = [
|
||||
{ key: 'all', label: 'Alle', count: stats.total },
|
||||
{ key: 'open', label: 'Offen', count: stats.open },
|
||||
{ key: 'acceptable', label: 'Akzeptabel', count: stats.acceptable },
|
||||
{ key: 'not_acceptable', label: 'Nicht akzeptabel', count: stats.notAcceptable },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
Restrisiko-Iteration
|
||||
</h2>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">ISO 12100 Schritt 3</span>
|
||||
</div>
|
||||
|
||||
{/* Summary stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-center text-xs">
|
||||
<div className="bg-gray-50 dark:bg-gray-750 rounded-lg p-2">
|
||||
<div className="text-lg font-bold text-gray-900 dark:text-white">{stats.assessed}/{stats.total}</div>
|
||||
<div className="text-gray-500">Bewertet</div>
|
||||
</div>
|
||||
<div className="bg-green-50 dark:bg-green-900/20 rounded-lg p-2">
|
||||
<div className="text-lg font-bold text-green-700 dark:text-green-400">{stats.acceptable}</div>
|
||||
<div className="text-green-600 dark:text-green-500">Akzeptabel</div>
|
||||
</div>
|
||||
<div className="bg-red-50 dark:bg-red-900/20 rounded-lg p-2">
|
||||
<div className="text-lg font-bold text-red-700 dark:text-red-400">{stats.notAcceptable}</div>
|
||||
<div className="text-red-600 dark:text-red-500">Nicht akzeptabel</div>
|
||||
</div>
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 rounded-lg p-2">
|
||||
<div className="text-lg font-bold text-yellow-700 dark:text-yellow-400">{stats.open}</div>
|
||||
<div className="text-yellow-600 dark:text-yellow-500">Offen</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-xs text-gray-500 mb-1">
|
||||
<span>{stats.assessed} von {stats.total} Gefaehrdungen bewertet</span>
|
||||
<span>{pct}%</span>
|
||||
</div>
|
||||
<div className="w-full h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-300 bg-gradient-to-r from-purple-500 to-purple-600"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter buttons */}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{filters.map(f => (
|
||||
<button
|
||||
key={f.key}
|
||||
onClick={() => onFilterChange(f.key)}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
|
||||
activeFilter === f.key
|
||||
? 'bg-purple-600 text-white'
|
||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
{f.label} ({f.count})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+341
@@ -0,0 +1,341 @@
|
||||
'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
|
||||
/** Explicit accept/reject decisions per hazard ID (true=acceptable, false=not, null=undecided). */
|
||||
decisions?: Record<string, boolean | null>
|
||||
/** Called when user toggles the accept/reject for a hazard. */
|
||||
onDecision?: (hazardId: string, acceptable: boolean | null) => 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, decisions, onDecision }: RiskAssessmentTableProps) {
|
||||
const [mitCounts, setMitCounts] = useState<Record<string, number>>({})
|
||||
const [edits, setEdits] = useState<Record<string, EditState>>({})
|
||||
const [saving, setSaving] = useState<string | null>(null)
|
||||
const [normsByCategory, setNormsByCategory] = useState<Record<string, string[]>>({})
|
||||
|
||||
// Fetch norms library and build category→norm-numbers map
|
||||
useEffect(() => {
|
||||
fetch(`/api/sdk/v1/iace/norms-library`)
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(json => {
|
||||
if (!json?.norms) return
|
||||
const map: Record<string, string[]> = {}
|
||||
for (const n of json.norms) {
|
||||
for (const cat of (n.hazard_cats || [])) {
|
||||
if (!map[cat]) map[cat] = []
|
||||
if (map[cat].length < 3) map[cat].push(n.number)
|
||||
}
|
||||
}
|
||||
setNormsByCategory(map)
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
// 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 PAGE_SIZE = 50
|
||||
const [page, setPage] = useState(0)
|
||||
const sorted = [...hazards].sort((a, b) => (b.r_inherent || 0) - (a.r_inherent || 0))
|
||||
const totalPages = Math.ceil(sorted.length / PAGE_SIZE)
|
||||
const paged = sorted.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE)
|
||||
|
||||
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>
|
||||
<div className="flex items-center gap-3">
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
<button onClick={() => setPage(Math.max(0, page - 1))} disabled={page === 0}
|
||||
className="px-2 py-1 rounded border border-gray-200 hover:bg-gray-50 disabled:opacity-30 disabled:cursor-not-allowed"><</button>
|
||||
<span className="px-2 text-gray-600">Seite {page + 1} / {totalPages}</span>
|
||||
<button onClick={() => setPage(Math.min(totalPages - 1, page + 1))} disabled={page >= totalPages - 1}
|
||||
className="px-2 py-1 rounded border border-gray-200 hover:bg-gray-50 disabled:opacity-30 disabled:cursor-not-allowed">></button>
|
||||
</div>
|
||||
)}
|
||||
<span className="text-xs text-gray-500">{hazards.length} Gefaehrdungen</span>
|
||||
</div>
|
||||
</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">
|
||||
{paged.map(h => {
|
||||
const ra = (h as Record<string, unknown>).risk_assessment as Record<string, number> | null
|
||||
const initS = ra?.severity || h.severity || 3
|
||||
const initE = ra?.exposure || h.exposure || 3
|
||||
const initP = ra?.probability || h.probability || 3
|
||||
const initA = h.avoidance || 0
|
||||
const e = edits[h.id]
|
||||
const initRpz = rpz(initS, initE, initP, initA)
|
||||
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 !== initS || e.exposure !== initE || e.probability !== initP)
|
||||
|
||||
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 min-w-[250px]">
|
||||
<div className="font-medium text-gray-900 dark:text-white">{h.name}</div>
|
||||
{h.component_name && <div className="text-[10px] text-gray-400">{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>
|
||||
{normsByCategory[h.category]?.length > 0 && (
|
||||
<div className="text-[9px] text-blue-500 mt-0.5 truncate" title={normsByCategory[h.category].join(', ')}>
|
||||
{normsByCategory[h.category].slice(0, 2).join(', ')}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
{/* Initial S/E/P/RPZ/Risk */}
|
||||
<td className="px-2 py-2 text-center text-gray-700 dark:text-gray-300">{initS}</td>
|
||||
<td className="px-2 py-2 text-center text-gray-700 dark:text-gray-300">{initE}</td>
|
||||
<td className="px-2 py-2 text-center text-gray-700 dark:text-gray-300">{initP}</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">
|
||||
{onDecision ? (
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<button onClick={() => onDecision(h.id, decisions?.[h.id] === true ? null : true)}
|
||||
title="Akzeptabel"
|
||||
className={`w-5 h-5 rounded-full text-[10px] leading-5 text-center transition-colors ${
|
||||
decisions?.[h.id] === true
|
||||
? 'bg-green-500 text-white ring-2 ring-green-300'
|
||||
: 'bg-gray-200 dark:bg-gray-600 text-gray-500 dark:text-gray-400 hover:bg-green-200'
|
||||
}`}>✓</button>
|
||||
<button onClick={() => onDecision(h.id, decisions?.[h.id] === false ? null : false)}
|
||||
title="Nicht akzeptabel"
|
||||
className={`w-5 h-5 rounded-full text-[10px] leading-5 text-center transition-colors ${
|
||||
decisions?.[h.id] === false
|
||||
? 'bg-red-500 text-white ring-2 ring-red-300'
|
||||
: 'bg-gray-200 dark:bg-gray-600 text-gray-500 dark:text-gray-400 hover:bg-red-200'
|
||||
}`}>✗</button>
|
||||
</div>
|
||||
) : (
|
||||
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,
|
||||
fetchLibrary, handleAddFromLibrary, handleSubmit,
|
||||
handleAISuggestions, handlePatternMatching, handleApplyPatterns, handleDelete,
|
||||
refetch: fetchHazards,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,48 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import React, { useState, useMemo, useCallback } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { HazardForm } from './_components/HazardForm'
|
||||
import { HazardTable } from './_components/HazardTable'
|
||||
import { RiskAssessmentTable } from './_components/RiskAssessmentTable'
|
||||
import { ResidualRiskPanel, getResidualStatus } from './_components/ResidualRiskPanel'
|
||||
import type { ResidualFilter } from './_components/ResidualRiskPanel'
|
||||
import { LibraryModal } from './_components/LibraryModal'
|
||||
import { AutoSuggestPanel } from './_components/AutoSuggestPanel'
|
||||
import { CustomHazardModal } from './_components/CustomHazardModal'
|
||||
import { useHazards } from './_hooks/useHazards'
|
||||
|
||||
type ViewMode = 'list' | 'risk'
|
||||
|
||||
export default function HazardsPage() {
|
||||
const params = useParams()
|
||||
const projectId = params.projectId as string
|
||||
const h = useHazards(projectId)
|
||||
const [view, setView] = useState<ViewMode>('risk')
|
||||
const [showCustomModal, setShowCustomModal] = useState(false)
|
||||
const [residualFilter, setResidualFilter] = useState<ResidualFilter>('all')
|
||||
const [decisions, setDecisions] = useState<Record<string, boolean | null>>({})
|
||||
|
||||
const handleDecision = useCallback(async (hazardId: string, acceptable: boolean | null) => {
|
||||
setDecisions(prev => ({ ...prev, [hazardId]: acceptable }))
|
||||
if (acceptable !== null) {
|
||||
try {
|
||||
await fetch(`/api/sdk/v1/iace/projects/${projectId}/hazards/${hazardId}/reassess`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ hazard_id: hazardId, is_acceptable: acceptable }),
|
||||
})
|
||||
} catch (err) { console.error('Decision save failed:', err) }
|
||||
}
|
||||
}, [projectId])
|
||||
|
||||
const filteredHazards = useMemo(() => {
|
||||
if (residualFilter === 'all') return h.hazards
|
||||
return h.hazards.filter(hz => {
|
||||
const status = getResidualStatus(hz, decisions[hz.id] ?? null)
|
||||
return status === residualFilter
|
||||
})
|
||||
}, [h.hazards, decisions, residualFilter])
|
||||
|
||||
if (h.loading) {
|
||||
return (
|
||||
@@ -29,9 +60,20 @@ export default function HazardsPage() {
|
||||
<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).
|
||||
</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 className="flex items-center gap-2">
|
||||
<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">
|
||||
{h.matchingPatterns ? (
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-green-600" />
|
||||
@@ -40,18 +82,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" />
|
||||
</svg>
|
||||
)}
|
||||
Auto-Erkennung
|
||||
</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
|
||||
Gefaehrdungen erkennen
|
||||
</button>
|
||||
<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">
|
||||
@@ -60,6 +91,13 @@ export default function HazardsPage() {
|
||||
</svg>
|
||||
Aus Bibliothek
|
||||
</button>
|
||||
<button onClick={() => setShowCustomModal(true)}
|
||||
className="flex items-center gap-2 px-3 py-2 border border-orange-300 text-orange-700 rounded-lg hover:bg-orange-50 transition-colors text-sm">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
Eigene Gefaehrdung
|
||||
</button>
|
||||
<button onClick={() => h.setShowForm(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors text-sm">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
@@ -70,12 +108,12 @@ export default function HazardsPage() {
|
||||
</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}
|
||||
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">
|
||||
<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" />
|
||||
@@ -120,8 +158,23 @@ export default function HazardsPage() {
|
||||
<LibraryModal library={h.library} onAdd={h.handleAddFromLibrary} onClose={() => h.setShowLibrary(false)} />
|
||||
)}
|
||||
|
||||
{showCustomModal && (
|
||||
<CustomHazardModal roles={h.roles}
|
||||
onSubmit={async (data) => { await h.handleSubmit(data); setShowCustomModal(false) }}
|
||||
onClose={() => setShowCustomModal(false)} />
|
||||
)}
|
||||
|
||||
{h.hazards.length > 0 ? (
|
||||
<HazardTable hazards={h.hazards} lifecyclePhases={h.lifecyclePhases} onDelete={h.handleDelete} />
|
||||
view === 'risk' ? (
|
||||
<>
|
||||
<ResidualRiskPanel hazards={h.hazards} decisions={decisions}
|
||||
activeFilter={residualFilter} onFilterChange={setResidualFilter} />
|
||||
<RiskAssessmentTable projectId={projectId} hazards={filteredHazards}
|
||||
onReassess={h.refetch} decisions={decisions} onDecision={handleDecision} />
|
||||
</>
|
||||
) : (
|
||||
<HazardTable hazards={h.hazards} lifecyclePhases={h.lifecyclePhases} onDelete={h.handleDelete} />
|
||||
)
|
||||
) : (
|
||||
!h.showForm && (
|
||||
<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,125 @@
|
||||
'use client'
|
||||
|
||||
interface TextInputProps {
|
||||
label: string
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
helpText?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function TextInput({ label, value, onChange, placeholder, helpText, disabled }: TextInputProps) {
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{label}</label>
|
||||
{helpText && <p className="text-xs text-gray-400 mb-1.5">{helpText}</p>}
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-600 rounded-lg text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:ring-purple-500 focus:border-purple-500 disabled:bg-gray-50 dark:disabled:bg-gray-800 disabled:text-gray-400"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface TextAreaProps {
|
||||
label: string
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
helpText?: string
|
||||
rows?: number
|
||||
}
|
||||
|
||||
export function TextArea({ label, value, onChange, placeholder, helpText, rows = 6 }: TextAreaProps) {
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{label}</label>
|
||||
{helpText && <p className="text-xs text-gray-400 mb-1.5">{helpText}</p>}
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-600 rounded-lg text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:ring-purple-500 focus:border-purple-500 resize-y"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface SelectInputProps {
|
||||
label: string
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
options: string[]
|
||||
placeholder?: string
|
||||
helpText?: string
|
||||
}
|
||||
|
||||
export function SelectInput({ label, value, onChange, options, placeholder, helpText }: SelectInputProps) {
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{label}</label>
|
||||
{helpText && <p className="text-xs text-gray-400 mb-1.5">{helpText}</p>}
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-600 rounded-lg text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
>
|
||||
<option value="">{placeholder || '-- Bitte waehlen --'}</option>
|
||||
{options.map((opt) => (
|
||||
<option key={opt} value={opt}>{opt}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface CheckboxGroupProps {
|
||||
label: string
|
||||
values: string[]
|
||||
onChange: (values: string[]) => void
|
||||
options: string[]
|
||||
helpText?: string
|
||||
}
|
||||
|
||||
export function CheckboxGroup({ label, values, onChange, options, helpText }: CheckboxGroupProps) {
|
||||
const toggle = (opt: string) => {
|
||||
if (values.includes(opt)) {
|
||||
onChange(values.filter((v) => v !== opt))
|
||||
} else {
|
||||
onChange([...values, opt])
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{label}</label>
|
||||
{helpText && <p className="text-xs text-gray-400 mb-1.5">{helpText}</p>}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{options.map((opt) => (
|
||||
<label
|
||||
key={opt}
|
||||
className={`flex items-center gap-2 px-3 py-1.5 text-sm rounded-lg border cursor-pointer transition-colors ${
|
||||
values.includes(opt)
|
||||
? 'bg-purple-50 border-purple-300 text-purple-700 dark:bg-purple-900/40 dark:border-purple-600 dark:text-purple-300'
|
||||
: 'bg-white border-gray-200 text-gray-700 hover:bg-gray-50 dark:bg-gray-700 dark:border-gray-600 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={values.includes(opt)}
|
||||
onChange={() => toggle(opt)}
|
||||
className="w-3.5 h-3.5 text-purple-600 rounded"
|
||||
/>
|
||||
{opt}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
'use client'
|
||||
|
||||
import { SectionCard } from './SectionCard'
|
||||
import { TextInput, TextArea, SelectInput, CheckboxGroup } from './FormFields'
|
||||
import {
|
||||
FORM_SECTIONS,
|
||||
AREA_OF_USE_OPTIONS,
|
||||
OPERATING_MODE_OPTIONS,
|
||||
PERSON_GROUP_OPTIONS,
|
||||
type LimitsFormData,
|
||||
} from '../_types'
|
||||
|
||||
interface LimitsFormSectionsProps {
|
||||
data: LimitsFormData
|
||||
onChange: (field: keyof LimitsFormData, value: string | string[]) => void
|
||||
prefilled: { machine_name?: string; machine_type?: string; manufacturer?: string }
|
||||
}
|
||||
|
||||
export function LimitsFormSections({ data, onChange, prefilled }: LimitsFormSectionsProps) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Section 1: Allgemeine Produktbeschreibung */}
|
||||
<SectionCard section={FORM_SECTIONS[0]} defaultOpen>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<TextInput
|
||||
label="Maschinenbezeichnung *"
|
||||
value={data.machine_designation || prefilled.machine_name || ''}
|
||||
onChange={(v) => onChange('machine_designation', v)}
|
||||
placeholder="z.B. Roboterzelle RZ-500"
|
||||
helpText={prefilled.machine_name ? `Vorausgefuellt aus Projekt: ${prefilled.machine_name}` : undefined}
|
||||
/>
|
||||
<TextInput
|
||||
label="Maschinentyp *"
|
||||
value={data.machine_type || prefilled.machine_type || ''}
|
||||
onChange={(v) => onChange('machine_type', v)}
|
||||
placeholder="z.B. Roboterzelle / CNC-Maschine"
|
||||
helpText={prefilled.machine_type ? `Vorausgefuellt aus Projekt: ${prefilled.machine_type}` : undefined}
|
||||
/>
|
||||
<TextInput
|
||||
label="Hersteller *"
|
||||
value={data.manufacturer || prefilled.manufacturer || ''}
|
||||
onChange={(v) => onChange('manufacturer', v)}
|
||||
placeholder="z.B. Mueller Maschinenbau GmbH"
|
||||
helpText={prefilled.manufacturer ? `Vorausgefuellt aus Projekt: ${prefilled.manufacturer}` : undefined}
|
||||
/>
|
||||
<TextInput
|
||||
label="Baujahr"
|
||||
value={data.year_of_construction}
|
||||
onChange={(v) => onChange('year_of_construction', v)}
|
||||
placeholder="z.B. 2026"
|
||||
/>
|
||||
<TextInput
|
||||
label="Seriennummer"
|
||||
value={data.serial_number}
|
||||
onChange={(v) => onChange('serial_number', v)}
|
||||
placeholder="z.B. SN-2026-001"
|
||||
/>
|
||||
</div>
|
||||
<TextArea
|
||||
label="Allgemeine Beschreibung *"
|
||||
value={data.general_description}
|
||||
onChange={(v) => onChange('general_description', v)}
|
||||
placeholder="Die EIGENBAU-Zelle ist ein Arbeitstisch mit integriertem Roboterarm, der Bauteile aus einem Magazin entnimmt und dem Bearbeitungszentrum zufuehrt..."
|
||||
helpText="Beschreiben Sie Aufbau, Funktion und Arbeitsweise der Maschine/Anlage ausfuehrlich."
|
||||
rows={12}
|
||||
/>
|
||||
</SectionCard>
|
||||
|
||||
{/* Section 2: Bestimmungsgemasse Verwendung */}
|
||||
<SectionCard section={FORM_SECTIONS[1]}>
|
||||
<TextArea
|
||||
label="Verwendungszweck *"
|
||||
value={data.intended_purpose}
|
||||
onChange={(v) => onChange('intended_purpose', v)}
|
||||
placeholder="Zum Einsatz an Bearbeitungszentren, zur Zufuehrung von Bauteilen aus einem Magazin in die Bearbeitungsmaschine..."
|
||||
helpText="Beschreiben Sie den bestimmungsgemassen Einsatzzweck der Maschine."
|
||||
rows={4}
|
||||
/>
|
||||
<SelectInput
|
||||
label="Einsatzbereich *"
|
||||
value={data.area_of_use}
|
||||
onChange={(v) => onChange('area_of_use', v)}
|
||||
options={AREA_OF_USE_OPTIONS}
|
||||
/>
|
||||
<CheckboxGroup
|
||||
label="Betriebsarten"
|
||||
values={data.operating_modes}
|
||||
onChange={(v) => onChange('operating_modes', v)}
|
||||
options={OPERATING_MODE_OPTIONS}
|
||||
helpText="Waehlen Sie alle zutreffenden Betriebsarten."
|
||||
/>
|
||||
<TextArea
|
||||
label="Varianten"
|
||||
value={data.variants}
|
||||
onChange={(v) => onChange('variants', v)}
|
||||
placeholder="Variante A: nicht-kollaborierend mit Schutzzaun Variante B: kollaborierend mit Kraft-/Leistungsbegrenzung"
|
||||
helpText="Beschreiben Sie verschiedene Ausbauvarianten oder Konfigurationen der Maschine."
|
||||
rows={3}
|
||||
/>
|
||||
</SectionCard>
|
||||
|
||||
{/* Section 3: Vorhersehbare Fehlanwendung */}
|
||||
<SectionCard section={FORM_SECTIONS[2]}>
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-3 mb-2">
|
||||
<p className="text-xs text-amber-700 dark:text-amber-300">
|
||||
Dokumentieren Sie alle vernuenftigerweise vorhersehbaren Fehlanwendungen gemaess ISO 12100 Abschnitt 5.4. Beruecksichtigen Sie dabei reflexartiges Verhalten, mangelnde Konzentration und Verhaltensweisen nach dem Grundsatz des geringsten Widerstandes.
|
||||
</p>
|
||||
</div>
|
||||
<TextArea
|
||||
label="Vorhersehbare Fehlanwendungen *"
|
||||
value={data.foreseeable_misuses}
|
||||
onChange={(v) => onChange('foreseeable_misuses', v)}
|
||||
placeholder="- Eingriff in laufende Maschine bei Stoerung - Umgehung von Schutzeinrichtungen (Tuerschalter ueberbrueckt) - Betrieb mit offenem Schutzzaun - Unqualifiziertes Personal fuehrt Wartungsarbeiten durch - Verwendung nicht freigegebener Werkzeuge/Materialien"
|
||||
helpText="Jeweils eine Fehlanwendung pro Zeile, mit Stichpunkt-Aufzaehlung."
|
||||
rows={10}
|
||||
/>
|
||||
</SectionCard>
|
||||
|
||||
{/* Section 4: Grenzen der Maschine */}
|
||||
<SectionCard section={FORM_SECTIONS[3]}>
|
||||
<TextArea
|
||||
label="Raeumliche Grenzen *"
|
||||
value={data.spatial_limits}
|
||||
onChange={(v) => onChange('spatial_limits', v)}
|
||||
placeholder="Abmessungen: 2000 x 1500 x 1800 mm (LxBxH) Arbeitsraum Roboter: Radius 850mm Zugangsbereich: nur von vorne Sicherheitsabstand: min. 500mm zum Schutzzaun"
|
||||
helpText="Abmessungen, Arbeitsraum, Zugangsbereich, Sicherheitsabstaende."
|
||||
rows={4}
|
||||
/>
|
||||
<TextArea
|
||||
label="Zeitliche Grenzen"
|
||||
value={data.temporal_limits}
|
||||
onChange={(v) => onChange('temporal_limits', v)}
|
||||
placeholder="Geplante Lebensdauer: 15 Jahre Wartungsintervall: alle 2000 Betriebsstunden Max. Betriebsdauer pro Tag: 16 Stunden (2-Schicht)"
|
||||
helpText="Lebensdauer, Wartungsintervalle, Nutzungsdauer pro Tag/Woche."
|
||||
rows={3}
|
||||
/>
|
||||
<TextArea
|
||||
label="Betriebsbedingungen"
|
||||
value={data.operating_conditions}
|
||||
onChange={(v) => onChange('operating_conditions', v)}
|
||||
placeholder="Temperatur: +5 bis +40 Grad C Luftfeuchtigkeit: max. 80% (nicht kondensierend) Hoehenlage: bis 1000m ue.NN Keine explosionsgefaehrdete Atmosphaere"
|
||||
helpText="Temperatur, Feuchtigkeit, Staub, Vibrationen, besondere Umgebungsbedingungen."
|
||||
rows={4}
|
||||
/>
|
||||
<TextArea
|
||||
label="Energieversorgung"
|
||||
value={data.energy_supply}
|
||||
onChange={(v) => onChange('energy_supply', v)}
|
||||
placeholder="Elektrisch: 400V/50Hz, 32A Absicherung Druckluft: 6 bar, oelfrei Pneumatik: 6 bar Betriebsdruck"
|
||||
helpText="Spannung, Absicherung, Druckluftversorgung, weitere Energiequellen."
|
||||
rows={3}
|
||||
/>
|
||||
</SectionCard>
|
||||
|
||||
{/* Section 5: Schnittstellen */}
|
||||
<SectionCard section={FORM_SECTIONS[4]}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<TextArea
|
||||
label="Mechanische Schnittstellen"
|
||||
value={data.mechanical_interfaces}
|
||||
onChange={(v) => onChange('mechanical_interfaces', v)}
|
||||
placeholder="- Flanschverbindung zum Bearbeitungszentrum - Magazin-Andockstation - Greifer-Wechselsystem"
|
||||
rows={3}
|
||||
/>
|
||||
<TextArea
|
||||
label="Elektrische Schnittstellen"
|
||||
value={data.electrical_interfaces}
|
||||
onChange={(v) => onChange('electrical_interfaces', v)}
|
||||
placeholder="- ProfiNET Steuerungsbus - 24V Sicherheitskreis - E/A-Module fuer Sensorik"
|
||||
rows={3}
|
||||
/>
|
||||
<TextArea
|
||||
label="Software-Schnittstellen"
|
||||
value={data.software_interfaces}
|
||||
onChange={(v) => onChange('software_interfaces', v)}
|
||||
placeholder="- OPC UA Server - REST API fuer MES-Anbindung - HMI Webinterface"
|
||||
rows={3}
|
||||
/>
|
||||
<TextArea
|
||||
label="Pneumatische/Hydraulische Schnittstellen"
|
||||
value={data.pneumatic_hydraulic_interfaces}
|
||||
onChange={(v) => onChange('pneumatic_hydraulic_interfaces', v)}
|
||||
placeholder="- 6mm Druckluftanschluss - Wartungseinheit mit Filter/Regler - Abluft ueber Schalldaempfer"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
{/* Section 6: Betroffene Personen */}
|
||||
<SectionCard section={FORM_SECTIONS[5]}>
|
||||
<CheckboxGroup
|
||||
label="Personengruppen"
|
||||
values={data.person_groups}
|
||||
onChange={(v) => onChange('person_groups', v)}
|
||||
options={PERSON_GROUP_OPTIONS}
|
||||
helpText="Waehlen Sie alle Personengruppen, die mit der Maschine in Beruehrung kommen koennen."
|
||||
/>
|
||||
<TextArea
|
||||
label="Qualifikationsanforderungen"
|
||||
value={data.qualification_requirements}
|
||||
onChange={(v) => onChange('qualification_requirements', v)}
|
||||
placeholder="Bedienpersonal: Unterweisung gemaess Betriebsanleitung, min. 18 Jahre Einrichter: Facharbeiter Mechatronik + Herstellerschulung Wartungspersonal: Elektrofachkraft fuer Elektroanschluss, Mechatroniker fuer mechanische Wartung"
|
||||
helpText="Mindestqualifikation je Personengruppe mit Verweis auf erforderliche Schulungen."
|
||||
rows={4}
|
||||
/>
|
||||
</SectionCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
'use client'
|
||||
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import type { FormSection } from '../_types'
|
||||
|
||||
function SectionIcon({ icon, className }: { icon: FormSection['icon']; className?: string }) {
|
||||
const cls = className || 'w-5 h-5'
|
||||
switch (icon) {
|
||||
case 'clipboard':
|
||||
return (
|
||||
<svg className={cls} 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 2" />
|
||||
</svg>
|
||||
)
|
||||
case 'target':
|
||||
return (
|
||||
<svg className={cls} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
)
|
||||
case 'alert':
|
||||
return (
|
||||
<svg className={cls} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
)
|
||||
case 'box':
|
||||
return (
|
||||
<svg className={cls} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
)
|
||||
case 'link':
|
||||
return (
|
||||
<svg className={cls} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
||||
</svg>
|
||||
)
|
||||
case 'users':
|
||||
return (
|
||||
<svg className={cls} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
</svg>
|
||||
)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
interface SectionCardProps {
|
||||
section: FormSection
|
||||
defaultOpen?: boolean
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function SectionCard({ section, defaultOpen = false, children }: SectionCardProps) {
|
||||
const [open, setOpen] = useState(defaultOpen)
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
className="w-full flex items-center gap-4 px-6 py-4 text-left hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors"
|
||||
>
|
||||
<div className="w-10 h-10 bg-purple-50 dark:bg-purple-900/30 rounded-lg flex items-center justify-center text-purple-600 flex-shrink-0">
|
||||
<SectionIcon icon={section.icon} className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{section.number}. {section.title}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{section.description}
|
||||
</div>
|
||||
</div>
|
||||
<svg
|
||||
className={`w-5 h-5 text-gray-400 transition-transform flex-shrink-0 ${open ? '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>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="px-6 pb-6 pt-2 border-t border-gray-100 dark:border-gray-700 space-y-4">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
// IACE Limits & Intended Use Form Types — CE Risk Assessment Step 3
|
||||
// Based on ISO 12100 Sections 5.3 (Intended Use) and 5.4 (Limits)
|
||||
|
||||
/** Full form data structure stored in project metadata.limits_form */
|
||||
export interface LimitsFormData {
|
||||
// Section 1: Allgemeine Produktbeschreibung
|
||||
machine_designation: string
|
||||
machine_type: string
|
||||
manufacturer: string
|
||||
year_of_construction: string
|
||||
serial_number: string
|
||||
general_description: string
|
||||
|
||||
// Section 2: Bestimmungsgemasse Verwendung
|
||||
intended_purpose: string
|
||||
area_of_use: string
|
||||
operating_modes: string[]
|
||||
variants: string
|
||||
|
||||
// Section 3: Vorhersehbare Fehlanwendung
|
||||
foreseeable_misuses: string
|
||||
|
||||
// Section 4: Grenzen der Maschine
|
||||
spatial_limits: string
|
||||
temporal_limits: string
|
||||
operating_conditions: string
|
||||
energy_supply: string
|
||||
|
||||
// Section 5: Schnittstellen
|
||||
mechanical_interfaces: string
|
||||
electrical_interfaces: string
|
||||
software_interfaces: string
|
||||
pneumatic_hydraulic_interfaces: string
|
||||
|
||||
// Section 6: Betroffene Personen
|
||||
person_groups: string[]
|
||||
qualification_requirements: string
|
||||
}
|
||||
|
||||
export const EMPTY_LIMITS_FORM: LimitsFormData = {
|
||||
machine_designation: '',
|
||||
machine_type: '',
|
||||
manufacturer: '',
|
||||
year_of_construction: '',
|
||||
serial_number: '',
|
||||
general_description: '',
|
||||
intended_purpose: '',
|
||||
area_of_use: '',
|
||||
operating_modes: [],
|
||||
variants: '',
|
||||
foreseeable_misuses: '',
|
||||
spatial_limits: '',
|
||||
temporal_limits: '',
|
||||
operating_conditions: '',
|
||||
energy_supply: '',
|
||||
mechanical_interfaces: '',
|
||||
electrical_interfaces: '',
|
||||
software_interfaces: '',
|
||||
pneumatic_hydraulic_interfaces: '',
|
||||
person_groups: [],
|
||||
qualification_requirements: '',
|
||||
}
|
||||
|
||||
export const AREA_OF_USE_OPTIONS = [
|
||||
'Industriell',
|
||||
'Gewerblich',
|
||||
'Privat',
|
||||
'Oeffentlich',
|
||||
]
|
||||
|
||||
export const OPERATING_MODE_OPTIONS = [
|
||||
'Automatikbetrieb',
|
||||
'Einrichtbetrieb',
|
||||
'Handbetrieb',
|
||||
'Sonderbetrieb',
|
||||
'Reinigung',
|
||||
'Wartung',
|
||||
]
|
||||
|
||||
export const PERSON_GROUP_OPTIONS = [
|
||||
'Bedienpersonal',
|
||||
'Einrichter',
|
||||
'Wartungspersonal',
|
||||
'Reinigungspersonal',
|
||||
'Auszubildende',
|
||||
'Besucher',
|
||||
'Fremdfirmenpersonal',
|
||||
]
|
||||
|
||||
/** Section definition for rendering collapsible form cards */
|
||||
export interface FormSection {
|
||||
id: string
|
||||
number: number
|
||||
title: string
|
||||
description: string
|
||||
icon: 'clipboard' | 'target' | 'alert' | 'box' | 'link' | 'users'
|
||||
}
|
||||
|
||||
export const FORM_SECTIONS: FormSection[] = [
|
||||
{
|
||||
id: 'product_description',
|
||||
number: 1,
|
||||
title: 'Allgemeine Produktbeschreibung',
|
||||
description: 'Grundlegende Angaben zur Maschine/Anlage',
|
||||
icon: 'clipboard',
|
||||
},
|
||||
{
|
||||
id: 'intended_use',
|
||||
number: 2,
|
||||
title: 'Bestimmungsgemasse Verwendung',
|
||||
description: 'Verwendungszweck, Einsatzbereich und Betriebsarten',
|
||||
icon: 'target',
|
||||
},
|
||||
{
|
||||
id: 'foreseeable_misuse',
|
||||
number: 3,
|
||||
title: 'Vorhersehbare Fehlanwendung',
|
||||
description: 'Vernuenftigerweise vorhersehbare Fehlanwendungen gemaess ISO 12100 Abschnitt 5.4',
|
||||
icon: 'alert',
|
||||
},
|
||||
{
|
||||
id: 'machine_limits',
|
||||
number: 4,
|
||||
title: 'Grenzen der Maschine',
|
||||
description: 'Raeumliche, zeitliche und betriebliche Grenzen',
|
||||
icon: 'box',
|
||||
},
|
||||
{
|
||||
id: 'interfaces',
|
||||
number: 5,
|
||||
title: 'Schnittstellen',
|
||||
description: 'Mechanische, elektrische, Software- und pneumatische/hydraulische Schnittstellen',
|
||||
icon: 'link',
|
||||
},
|
||||
{
|
||||
id: 'affected_persons',
|
||||
number: 6,
|
||||
title: 'Betroffene Personen',
|
||||
description: 'Personengruppen und Qualifikationsanforderungen',
|
||||
icon: 'users',
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,251 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { LimitsFormSections } from './_components/LimitsFormSections'
|
||||
import { EMPTY_LIMITS_FORM, type LimitsFormData } from './_types'
|
||||
|
||||
type SaveStatus = 'idle' | 'saving' | 'saved' | 'error'
|
||||
|
||||
interface ProjectData {
|
||||
machine_name: string
|
||||
machine_type: string
|
||||
manufacturer: string
|
||||
metadata?: {
|
||||
limits_form?: LimitsFormData
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export default function IACEInterviewPage() {
|
||||
const { projectId } = useParams<{ projectId: string }>()
|
||||
const router = useRouter()
|
||||
const [formData, setFormData] = useState<LimitsFormData>(EMPTY_LIMITS_FORM)
|
||||
const [projectData, setProjectData] = useState<ProjectData | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle')
|
||||
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const latestFormRef = useRef<LimitsFormData>(EMPTY_LIMITS_FORM)
|
||||
|
||||
// Load project data and existing form data
|
||||
useEffect(() => {
|
||||
loadProject()
|
||||
}, [projectId]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
async function loadProject() {
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}`)
|
||||
if (!res.ok) return
|
||||
const json = await res.json()
|
||||
const proj: ProjectData = {
|
||||
machine_name: json.machine_name || '',
|
||||
machine_type: json.machine_type || '',
|
||||
manufacturer: json.manufacturer || '',
|
||||
metadata: json.metadata || {},
|
||||
}
|
||||
setProjectData(proj)
|
||||
|
||||
// Restore saved form data from metadata
|
||||
if (proj.metadata?.limits_form) {
|
||||
const saved = proj.metadata.limits_form
|
||||
const merged = { ...EMPTY_LIMITS_FORM, ...saved }
|
||||
setFormData(merged)
|
||||
latestFormRef.current = merged
|
||||
} else {
|
||||
// Pre-fill from project fields
|
||||
const prefilled: LimitsFormData = {
|
||||
...EMPTY_LIMITS_FORM,
|
||||
machine_designation: proj.machine_name,
|
||||
machine_type: proj.machine_type,
|
||||
manufacturer: proj.manufacturer,
|
||||
}
|
||||
setFormData(prefilled)
|
||||
latestFormRef.current = prefilled
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load project:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Debounced auto-save
|
||||
const saveToBackend = useCallback(async (data: LimitsFormData) => {
|
||||
setSaveStatus('saving')
|
||||
try {
|
||||
// Merge limits_form into existing metadata
|
||||
const existingMetadata = projectData?.metadata || {}
|
||||
const newMetadata = { ...existingMetadata, limits_form: data }
|
||||
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ metadata: newMetadata }),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
setSaveStatus('saved')
|
||||
// Also update local projectData metadata so next save merges correctly
|
||||
setProjectData((prev) => prev ? { ...prev, metadata: newMetadata } : prev)
|
||||
setTimeout(() => setSaveStatus((s) => s === 'saved' ? 'idle' : s), 2000)
|
||||
} else {
|
||||
setSaveStatus('error')
|
||||
}
|
||||
} catch {
|
||||
setSaveStatus('error')
|
||||
}
|
||||
}, [projectId, projectData?.metadata]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleFieldChange = useCallback((field: keyof LimitsFormData, value: string | string[]) => {
|
||||
setFormData((prev) => {
|
||||
const next = { ...prev, [field]: value }
|
||||
latestFormRef.current = next
|
||||
return next
|
||||
})
|
||||
|
||||
// Debounce save: wait 1.5s after last change
|
||||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
|
||||
saveTimerRef.current = setTimeout(() => {
|
||||
saveToBackend(latestFormRef.current)
|
||||
}, 1500)
|
||||
}, [saveToBackend])
|
||||
|
||||
// Cleanup timer on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Calculate completion percentage
|
||||
const completionPct = calculateCompletion(formData)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
Grenzen & Verwendung
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
Schritt 3 — Bestimmungsgemasse Verwendung, Fehlanwendung und Maschinengrenzen definieren (ISO 12100)
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<SaveIndicator status={saveStatus} />
|
||||
<CompletionBadge pct={completionPct} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form Sections */}
|
||||
<LimitsFormSections
|
||||
data={formData}
|
||||
onChange={handleFieldChange}
|
||||
prefilled={{
|
||||
machine_name: projectData?.machine_name,
|
||||
machine_type: projectData?.machine_type,
|
||||
manufacturer: projectData?.manufacturer,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex items-center justify-between pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={() => router.push(`/sdk/iace/${projectId}`)}
|
||||
className="px-4 py-2 text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
>
|
||||
Zurueck zur Uebersicht
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
// Flush any pending save
|
||||
if (saveTimerRef.current) {
|
||||
clearTimeout(saveTimerRef.current)
|
||||
saveToBackend(latestFormRef.current)
|
||||
}
|
||||
router.push(`/sdk/iace/${projectId}/components`)
|
||||
}}
|
||||
className="flex items-center gap-2 px-5 py-2.5 bg-purple-600 text-white rounded-lg hover:bg-purple-700 text-sm font-medium transition-colors"
|
||||
>
|
||||
Weiter zu Komponenten
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7l5 5m0 0l-5 5m5-5H6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────
|
||||
// Sub-components (small, page-local)
|
||||
// ────────────────────────────────────────
|
||||
|
||||
function SaveIndicator({ status }: { status: SaveStatus }) {
|
||||
if (status === 'idle') return null
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
{status === 'saving' && (
|
||||
<>
|
||||
<div className="w-2 h-2 rounded-full bg-yellow-400 animate-pulse" />
|
||||
<span className="text-yellow-600 dark:text-yellow-400">Speichert...</span>
|
||||
</>
|
||||
)}
|
||||
{status === 'saved' && (
|
||||
<>
|
||||
<svg className="w-3.5 h-3.5 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span className="text-green-600 dark:text-green-400">Gespeichert</span>
|
||||
</>
|
||||
)}
|
||||
{status === 'error' && (
|
||||
<>
|
||||
<div className="w-2 h-2 rounded-full bg-red-500" />
|
||||
<span className="text-red-600 dark:text-red-400">Fehler beim Speichern</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CompletionBadge({ pct }: { pct: number }) {
|
||||
const color = pct >= 80 ? 'text-green-600 bg-green-50 border-green-200' :
|
||||
pct >= 40 ? 'text-yellow-600 bg-yellow-50 border-yellow-200' :
|
||||
'text-gray-500 bg-gray-50 border-gray-200'
|
||||
return (
|
||||
<span className={`px-2.5 py-1 rounded-full text-xs font-medium border ${color}`}>
|
||||
{pct}% ausgefuellt
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** Calculate how many required-ish fields are filled */
|
||||
function calculateCompletion(data: LimitsFormData): number {
|
||||
const checks = [
|
||||
!!data.machine_designation,
|
||||
!!data.machine_type,
|
||||
!!data.manufacturer,
|
||||
!!data.general_description,
|
||||
!!data.intended_purpose,
|
||||
!!data.area_of_use,
|
||||
data.operating_modes.length > 0,
|
||||
!!data.foreseeable_misuses,
|
||||
!!data.spatial_limits,
|
||||
!!data.operating_conditions,
|
||||
!!data.energy_supply,
|
||||
data.person_groups.length > 0,
|
||||
!!data.qualification_requirements,
|
||||
]
|
||||
const filled = checks.filter(Boolean).length
|
||||
return Math.round((filled / checks.length) * 100)
|
||||
}
|
||||
+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="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-white">{mitigation.title}</h4>
|
||||
{mitigation.title.startsWith('Auto:') && (
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-white">{mitigation.title || ''}</h4>
|
||||
{(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">
|
||||
Auto
|
||||
</span>
|
||||
@@ -28,7 +28,7 @@ export function MitigationCard({
|
||||
{mitigation.description && (
|
||||
<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="flex flex-wrap gap-1">
|
||||
{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}/hazards`),
|
||||
])
|
||||
if (mitRes.ok) {
|
||||
const json = await mitRes.json()
|
||||
const mits = json.mitigations || json || []
|
||||
setMitigations(mits)
|
||||
validateHierarchy(mits)
|
||||
}
|
||||
let hazardList: Hazard[] = []
|
||||
if (hazRes.ok) {
|
||||
const json = await hazRes.json()
|
||||
setHazards((json.hazards || json || []).map((h: Hazard) => ({ id: h.id, name: h.name, risk_level: h.risk_level, 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) {
|
||||
console.error('Failed to fetch data:', err)
|
||||
@@ -128,7 +146,7 @@ export function useMitigations(projectId: string) {
|
||||
|
||||
const byType = {
|
||||
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'),
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { REDUCTION_TYPES } from './_components/types'
|
||||
import { REDUCTION_TYPES, Mitigation } from './_components/types'
|
||||
import { HierarchyWarning } from './_components/HierarchyWarning'
|
||||
import { MeasuresLibraryModal } from './_components/MeasuresLibraryModal'
|
||||
import { SuggestMeasuresModal } from './_components/SuggestMeasuresModal'
|
||||
import { MitigationForm } from './_components/MitigationForm'
|
||||
import { MitigationCard } from './_components/MitigationCard'
|
||||
import { StatusBadge } from './_components/StatusBadge'
|
||||
import { ProtectiveMeasure } from './_components/types'
|
||||
import { useMitigations } from './_hooks/useMitigations'
|
||||
|
||||
@@ -21,11 +21,72 @@ export default function MitigationsPage() {
|
||||
fetchMeasuresLibrary, handleSubmit, handleAddSuggestedMeasure, handleVerify, handleDelete,
|
||||
} = useMitigations(projectId)
|
||||
|
||||
const [measureNorms, setMeasureNorms] = useState<Record<string, string[]>>({})
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/sdk/v1/iace/protective-measures-library')
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(json => {
|
||||
if (!json?.protective_measures) return
|
||||
const map: Record<string, string[]> = {}
|
||||
for (const m of json.protective_measures) {
|
||||
if (m.norm_references?.length > 0) {
|
||||
map[(m.name || '').toLowerCase()] = m.norm_references
|
||||
}
|
||||
}
|
||||
setMeasureNorms(map)
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [preselectedType, setPreselectedType] = useState<'design' | 'protection' | 'information' | undefined>()
|
||||
const [showLibrary, setShowLibrary] = useState(false)
|
||||
const [libraryFilter, setLibraryFilter] = useState<string | undefined>()
|
||||
const [showSuggest, setShowSuggest] = useState(false)
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({ design: true, protection: true, information: true })
|
||||
const [mitPages, setMitPages] = useState<Record<string, number>>({ design: 1, protection: 1, information: 1 })
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||
const [batchAction, setBatchAction] = useState<'verify' | 'delete' | null>(null)
|
||||
const [expandedMeasure, setExpandedMeasure] = useState<string | 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) {
|
||||
setLibraryFilter(type)
|
||||
@@ -39,11 +100,6 @@ export default function MitigationsPage() {
|
||||
setPreselectedType(measure.reduction_type as 'design' | 'protection' | 'information')
|
||||
}
|
||||
|
||||
function handleAddForType(type: 'design' | 'protection' | 'information') {
|
||||
setPreselectedType(type)
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
@@ -52,42 +108,51 @@ export default function MitigationsPage() {
|
||||
)
|
||||
}
|
||||
|
||||
const totalMeasures = byType.design.length + byType.protection.length + byType.information.length
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Massnahmen</h1>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Risikominderung nach dem 3-Stufen-Verfahren: Design → Schutz → Information.
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
{totalMeasures} Massnahmen nach 3-Stufen-Verfahren: Design ({byType.design.length}) → Schutz ({byType.protection.length}) → Information ({byType.information.length})
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{m.hazards.length > 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">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<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>
|
||||
Vorschlaege
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
{selected.size > 0 && (
|
||||
<>
|
||||
<span className="text-xs text-gray-500">{selected.size} ausgewaehlt</span>
|
||||
<button onClick={handleBatchVerify} disabled={batchAction !== null}
|
||||
className="px-3 py-1.5 text-xs bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50">
|
||||
{batchAction === 'verify' ? 'Verifiziere...' : 'Verifizieren'}
|
||||
</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>
|
||||
|
||||
@@ -95,76 +160,106 @@ export default function MitigationsPage() {
|
||||
|
||||
{showForm && (
|
||||
<MitigationForm
|
||||
onSubmit={async (data) => {
|
||||
const ok = await handleSubmit(data)
|
||||
if (ok) { setShowForm(false); setPreselectedType(undefined) }
|
||||
}}
|
||||
onCancel={() => { setShowForm(false); setPreselectedType(undefined) }}
|
||||
hazards={hazards}
|
||||
preselectedType={preselectedType}
|
||||
onOpenLibrary={handleOpenLibrary}
|
||||
onSubmit={async (data) => { const ok = await handleSubmit(data); if (ok) setShowForm(false) }}
|
||||
onCancel={() => setShowForm(false)} 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 && (
|
||||
<MeasuresLibraryModal
|
||||
measures={m.measures} onSelect={m.handleSelectMeasure}
|
||||
onClose={() => m.setShowLibrary(false)} filterType={m.libraryFilter}
|
||||
/>
|
||||
)}
|
||||
{/* 3-Step Accordions */}
|
||||
{(['design', 'protection', 'information'] as const).map((type) => {
|
||||
const config = REDUCTION_TYPES[type]
|
||||
const items = byType[type]
|
||||
const isExpanded = expanded[type]
|
||||
const allSelected = items.length > 0 && items.every((m) => selected.has(m.id))
|
||||
|
||||
{showSuggest && (
|
||||
<SuggestMeasuresModal
|
||||
hazards={m.hazards} projectId={projectId}
|
||||
onAddMeasure={m.handleAddSuggestedMeasure}
|
||||
onClose={() => m.setShowSuggest(false)}
|
||||
/>
|
||||
)}
|
||||
return (
|
||||
<div key={type} className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
{/* Accordion Header */}
|
||||
<button onClick={() => toggleSection(type)}
|
||||
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 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{(['design', 'protection', 'information'] as const).map((type) => {
|
||||
const config = REDUCTION_TYPES[type]
|
||||
const items = m.byType[type]
|
||||
return (
|
||||
<div key={type} className={`rounded-xl border ${config.color} p-4`}>
|
||||
<div className={`flex items-center gap-2 px-3 py-2 rounded-lg ${config.headerColor} mb-3`}>
|
||||
{config.icon}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">{config.label}</h3>
|
||||
<p className="text-xs opacity-75">{config.description}</p>
|
||||
{/* Accordion Content — Table rows */}
|
||||
{isExpanded && items.length > 0 && (
|
||||
<div className="border-t border-gray-100 dark:border-gray-700">
|
||||
{/* Table header */}
|
||||
<div className="grid grid-cols-[24px_2fr_1fr_80px] gap-2 px-4 py-2 bg-gray-50 dark:bg-gray-750 text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<div>
|
||||
<input type="checkbox" checked={allSelected} onChange={() => selectAllInType(type)}
|
||||
className="accent-purple-600" title="Alle auswaehlen" />
|
||||
</div>
|
||||
<div>Massnahme</div>
|
||||
<div>Gefaehrdung</div>
|
||||
<div>Status</div>
|
||||
</div>
|
||||
<span className="ml-auto text-sm font-bold">{items.length}</span>
|
||||
{/* Rows — paginated */}
|
||||
{items.slice(0, (mitPages[type] || 1) * 50).map((m) => {
|
||||
const isDetailOpen = expandedMeasure === m.id
|
||||
const catMatch = (m.description || '').match(/Kategorie\s+(\S+)/)
|
||||
const category = catMatch?.[1]
|
||||
const refs = measureNorms[(m.title || '').toLowerCase()]
|
||||
return (
|
||||
<div key={m.id}>
|
||||
<div onClick={() => setExpandedMeasure(isDetailOpen ? null : m.id)}
|
||||
className={`grid grid-cols-[24px_2fr_1fr_80px] 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 cursor-pointer ${selected.has(m.id) ? 'bg-purple-50 dark:bg-purple-900/10' : ''}`}>
|
||||
<div className="pt-0.5" onClick={(e) => e.stopPropagation()}>
|
||||
<input type="checkbox" checked={selected.has(m.id)} onChange={() => toggleSelect(m.id)}
|
||||
className="accent-purple-600" />
|
||||
</div>
|
||||
<div className="min-w-0 flex items-start gap-1">
|
||||
<svg className={`w-3 h-3 mt-1 shrink-0 text-gray-400 transition-transform ${isDetailOpen ? '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>
|
||||
<div>
|
||||
<div className="text-sm text-gray-900 dark:text-white">{m.title || ''}</div>
|
||||
{!isDetailOpen && category && <div className="text-[10px] text-gray-400 mt-0.5">Kategorie: {category}</div>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{(m.linked_hazard_names || []).join(', ') || '-'}
|
||||
</div>
|
||||
<div>
|
||||
<StatusBadge status={m.status} />
|
||||
</div>
|
||||
</div>
|
||||
{isDetailOpen && (
|
||||
<div className="px-12 py-3 bg-gray-50 dark:bg-gray-750 border-t border-gray-100 dark:border-gray-700 text-xs space-y-1">
|
||||
{m.description && <p className="text-gray-600 dark:text-gray-300">{m.description}</p>}
|
||||
{category && <p className="text-purple-600">Diese Massnahme gilt fuer alle Gefaehrdungen der Kategorie <strong>{category}</strong>.</p>}
|
||||
{refs?.length > 0 && <p className="text-blue-500">Normen: {refs.join(', ')}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{items.length > (mitPages[type] || 1) * 50 && (
|
||||
<button onClick={() => setMitPages(prev => ({ ...prev, [type]: (prev[type] || 1) + 1 }))}
|
||||
className="w-full py-2 text-xs text-purple-600 hover:bg-purple-50 border-t border-gray-100 transition-colors">
|
||||
Weitere {Math.min(50, items.length - (mitPages[type] || 1) * 50)} von {items.length} laden...
|
||||
</button>
|
||||
)}
|
||||
</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>
|
||||
))}
|
||||
)}
|
||||
|
||||
{isExpanded && items.length === 0 && (
|
||||
<div className="px-4 py-6 text-center text-sm text-gray-400 border-t border-gray-100">
|
||||
Keine Massnahmen in dieser Stufe
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{items.map((m) => (
|
||||
<MitigationCard key={m.id} mitigation={m} onVerify={handleVerify} onDelete={handleDelete} />
|
||||
))}
|
||||
</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">
|
||||
+ Hinzufuegen
|
||||
</button>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
|
||||
interface UploadedDoc {
|
||||
id: string
|
||||
filename: string
|
||||
size_bytes: number
|
||||
status: 'uploaded' | 'processing' | 'indexed'
|
||||
created_at: string
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
const STATUS_BADGE: Record<string, { label: string; cls: string }> = {
|
||||
uploaded: { label: 'Hochgeladen', cls: 'bg-gray-100 text-gray-600' },
|
||||
processing: { label: 'Wird verarbeitet', cls: 'bg-yellow-100 text-yellow-700 animate-pulse' },
|
||||
indexed: { label: 'Durchsuchbar', cls: 'bg-green-100 text-green-700' },
|
||||
}
|
||||
|
||||
const MAX_FILE_SIZE = 50 * 1024 * 1024 // 50 MB
|
||||
|
||||
export function DocumentUpload({ projectId }: { projectId: string }) {
|
||||
const [docs, setDocs] = useState<UploadedDoc[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [backendReady, setBackendReady] = useState(true)
|
||||
const [collapsed, setCollapsed] = useState(true)
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const base = `/api/sdk/v1/iace/projects/${projectId}/documents`
|
||||
|
||||
const fetchDocs = useCallback(async () => {
|
||||
try {
|
||||
const r = await fetch(base)
|
||||
if (r.status === 404) { setBackendReady(false); return }
|
||||
if (!r.ok) return
|
||||
const json = await r.json()
|
||||
setDocs(Array.isArray(json) ? json : json.documents ?? [])
|
||||
} catch {
|
||||
setBackendReady(false)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [base])
|
||||
|
||||
useEffect(() => { fetchDocs() }, [fetchDocs])
|
||||
|
||||
const uploadFiles = async (files: FileList | File[]) => {
|
||||
setError(null)
|
||||
const valid = Array.from(files).filter((f) => {
|
||||
if (!f.name.toLowerCase().endsWith('.pdf')) { setError('Nur PDF-Dateien erlaubt.'); return false }
|
||||
if (f.size > MAX_FILE_SIZE) { setError(`${f.name} ist groesser als 50 MB.`); return false }
|
||||
return true
|
||||
})
|
||||
if (valid.length === 0) return
|
||||
|
||||
setUploading(true)
|
||||
for (const file of valid) {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
try {
|
||||
const r = await fetch(base, { method: 'POST', body: form })
|
||||
if (r.status === 404) { setBackendReady(false); break }
|
||||
if (!r.ok) { setError(`Fehler beim Hochladen von ${file.name}`); continue }
|
||||
} catch {
|
||||
setBackendReady(false)
|
||||
break
|
||||
}
|
||||
}
|
||||
setUploading(false)
|
||||
await fetchDocs()
|
||||
}
|
||||
|
||||
const deleteDoc = async (docId: string) => {
|
||||
try {
|
||||
const r = await fetch(`${base}/${docId}`, { method: 'DELETE' })
|
||||
if (r.ok || r.status === 204) setDocs((prev) => prev.filter((d) => d.id !== docId))
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const onDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setDragging(false)
|
||||
if (e.dataTransfer.files.length) uploadFiles(e.dataTransfer.files)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
||||
{/* Header — collapsible toggle */}
|
||||
<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-purple-50 dark:bg-purple-900/30 rounded-lg flex items-center justify-center">
|
||||
<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="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
Dokumenten Upload{docs.length > 0 ? ` — ${docs.length} Dokument${docs.length !== 1 ? 'e' : ''}` : ''}
|
||||
</h2>
|
||||
<p className="text-xs text-gray-500">Eigene Normen und technische Dokumente hochladen</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">
|
||||
{/* Info text */}
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Laden Sie hier Ihre eigenen Dokumente hoch (z.B. Normen, technische Spezifikationen, Pruefberichte).
|
||||
Die Dokumente werden fuer Ihr Unternehmen indexiert und stehen in der Normenrecherche als zusaetzliche
|
||||
Quelle zur Verfuegung. Hochgeladene Dokumente sind nur fuer Ihren Mandanten sichtbar.
|
||||
</p>
|
||||
|
||||
{!backendReady ? (
|
||||
<div className="p-4 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 text-xs text-amber-800 dark:text-amber-300">
|
||||
<strong>Backend wird vorbereitet</strong> — Die Dokumenten-Upload-Funktion wird derzeit eingerichtet.
|
||||
Bitte versuchen Sie es spaeter erneut.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Drop zone */}
|
||||
<div
|
||||
onDragOver={(e) => { e.preventDefault(); setDragging(true) }}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={onDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className={`relative flex flex-col items-center justify-center gap-2 p-8 rounded-lg border-2 border-dashed cursor-pointer transition-colors ${
|
||||
dragging
|
||||
? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20'
|
||||
: 'border-gray-300 dark:border-gray-600 hover:border-purple-400 hover:bg-gray-50 dark:hover:bg-gray-700/50'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-8 h-8 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 16v-8m0 0l-3 3m3-3l3 3M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2" />
|
||||
</svg>
|
||||
<p className="text-xs text-gray-500">
|
||||
{uploading ? 'Wird hochgeladen...' : 'PDF-Dateien hierher ziehen oder klicken'}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">Max. 50 MB pro Datei</p>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".pdf"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => { if (e.target.files?.length) uploadFiles(e.target.files); e.target.value = '' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-red-600 dark:text-red-400">{error}</p>
|
||||
)}
|
||||
|
||||
{/* File list */}
|
||||
{loading ? null : docs.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{docs.map((doc) => {
|
||||
const badge = STATUS_BADGE[doc.status] ?? STATUS_BADGE.uploaded
|
||||
return (
|
||||
<div key={doc.id} className="flex items-center gap-3 p-2.5 rounded-lg bg-gray-50 dark:bg-gray-700/50 border border-gray-100 dark:border-gray-600">
|
||||
<svg className="w-4 h-4 text-red-500 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9l-5-5H9a2 2 0 00-2 2v13a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs font-medium text-gray-900 dark:text-white truncate">{doc.filename}</p>
|
||||
<p className="text-xs text-gray-400">{formatSize(doc.size_bytes)}</p>
|
||||
</div>
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full font-medium ${badge.cls}`}>{badge.label}</span>
|
||||
<button onClick={() => deleteDoc(doc.id)} className="p-1 text-gray-400 hover:text-red-500 transition-colors" title="Loeschen">
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
'use client'
|
||||
|
||||
import { useParams } from 'next/navigation'
|
||||
import { SuggestedNorms } from '../_components/SuggestedNorms'
|
||||
import { DocumentUpload } from './_components/DocumentUpload'
|
||||
|
||||
export default function NormsPage() {
|
||||
const params = useParams()
|
||||
const projectId = params.projectId as string
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Page header */}
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">Normenrecherche</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Relevante Normen fuer Ihr Produkt, automatisch ermittelt aus Maschinentyp, Gefaehrdungen
|
||||
und Komponenten. Ergaenzen Sie bei Bedarf weitere Normen manuell.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Info banner */}
|
||||
<div className="p-4 rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800">
|
||||
<div className="flex items-start gap-3">
|
||||
<svg className="w-5 h-5 text-blue-600 mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div className="text-xs text-blue-800 dark:text-blue-300">
|
||||
<strong>A-Normen</strong> (z.B. ISO 12100) gelten fuer alle Maschinen.{' '}
|
||||
<strong>B-Normen</strong> decken Sicherheitsaspekte ab (B1: Grundnormen, B2: Schutzeinrichtungen).{' '}
|
||||
<strong>C-Normen</strong> sind maschinenspezifisch und erzeugen eine Konformitaetsvermutung.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Suggested norms component — rendered expanded (not collapsed by default) */}
|
||||
<SuggestedNorms projectId={projectId} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
|
||||
interface OrderData {
|
||||
client: {
|
||||
company: string; contact: string; email: string; phone: string; address: string
|
||||
}
|
||||
order: {
|
||||
number: string; received_date: string; description: string; scope: string[]
|
||||
}
|
||||
offer: {
|
||||
date: string; number: string; amount: string; status: string; accepted_date: string
|
||||
}
|
||||
notes: string
|
||||
}
|
||||
|
||||
const EMPTY: OrderData = {
|
||||
client: { company: '', contact: '', email: '', phone: '', address: '' },
|
||||
order: { number: '', received_date: '', description: '', scope: [] },
|
||||
offer: { date: '', number: '', amount: '', status: 'offen', accepted_date: '' },
|
||||
notes: '',
|
||||
}
|
||||
|
||||
const SCOPE_OPTIONS = [
|
||||
'Risikobeurteilung', 'Normenrecherche', 'Betriebsanleitung', 'CE-Kennzeichnung', 'Schulung',
|
||||
]
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: 'offen', label: 'Offen' },
|
||||
{ value: 'angenommen', label: 'Angenommen' },
|
||||
{ value: 'abgelehnt', label: 'Abgelehnt' },
|
||||
{ value: 'storniert', label: 'Storniert' },
|
||||
]
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
offen: 'bg-gray-100 text-gray-700',
|
||||
angenommen: 'bg-green-100 text-green-700',
|
||||
abgelehnt: 'bg-red-100 text-red-700',
|
||||
storniert: 'bg-gray-100 text-gray-500',
|
||||
}
|
||||
|
||||
export default function OrderPage() {
|
||||
const params = useParams()
|
||||
const projectId = params.projectId as string
|
||||
const [data, setData] = useState<OrderData>(EMPTY)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saveState, setSaveState] = useState<'idle' | 'saving' | 'saved'>('idle')
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const existingMetaRef = useRef<Record<string, unknown>>({})
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/sdk/v1/iace/projects/${projectId}`)
|
||||
.then((r) => r.ok ? r.json() : null)
|
||||
.then((json) => {
|
||||
if (!json) return
|
||||
const proj = json.project || json
|
||||
const meta = proj.metadata || {}
|
||||
existingMetaRef.current = meta
|
||||
if (meta.order_data) setData({ ...EMPTY, ...meta.order_data })
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [projectId])
|
||||
|
||||
const save = useCallback(async (next: OrderData) => {
|
||||
setSaveState('saving')
|
||||
try {
|
||||
const merged = { ...existingMetaRef.current, order_data: next }
|
||||
await fetch(`/api/sdk/v1/iace/projects/${projectId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ metadata: merged }),
|
||||
})
|
||||
existingMetaRef.current = merged
|
||||
setSaveState('saved')
|
||||
setTimeout(() => setSaveState('idle'), 2000)
|
||||
} catch {
|
||||
setSaveState('idle')
|
||||
}
|
||||
}, [projectId])
|
||||
|
||||
const update = useCallback((next: OrderData) => {
|
||||
setData(next)
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
timerRef.current = setTimeout(() => save(next), 800)
|
||||
}, [save])
|
||||
|
||||
const setClient = (k: keyof OrderData['client'], v: string) =>
|
||||
update({ ...data, client: { ...data.client, [k]: v } })
|
||||
const setOrder = (k: keyof OrderData['order'], v: string) =>
|
||||
update({ ...data, order: { ...data.order, [k]: v } })
|
||||
const setOffer = (k: keyof OrderData['offer'], v: string) =>
|
||||
update({ ...data, offer: { ...data.offer, [k]: v } })
|
||||
const toggleScope = (s: string) => {
|
||||
const cur = data.order.scope
|
||||
const next = cur.includes(s) ? cur.filter((x) => x !== s) : [...cur, s]
|
||||
update({ ...data, order: { ...data.order, scope: next } })
|
||||
}
|
||||
|
||||
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-3xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Auftrag</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">Auftraggeber und Auftragsdaten erfassen</p>
|
||||
</div>
|
||||
<span className={`text-xs px-2.5 py-1 rounded-full font-medium transition-opacity ${
|
||||
saveState === 'saving' ? 'bg-yellow-100 text-yellow-700'
|
||||
: saveState === 'saved' ? 'bg-green-100 text-green-700'
|
||||
: 'opacity-0'
|
||||
}`}>
|
||||
{saveState === 'saving' ? 'Speichert...' : 'Gespeichert'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Auftraggeber */}
|
||||
<Card title="Auftraggeber">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Input label="Firmenname" value={data.client.company} onChange={(v) => setClient('company', v)} />
|
||||
<Input label="Ansprechpartner" value={data.client.contact} onChange={(v) => setClient('contact', v)} />
|
||||
<Input label="E-Mail" type="email" value={data.client.email} onChange={(v) => setClient('email', v)} />
|
||||
<Input label="Telefon" type="tel" value={data.client.phone} onChange={(v) => setClient('phone', v)} />
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<Textarea label="Adresse" value={data.client.address} onChange={(v) => setClient('address', v)} rows={2} />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Auftrag */}
|
||||
<Card title="Auftrag">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Input label="Auftragsnummer" value={data.order.number} onChange={(v) => setOrder('number', v)} />
|
||||
<Input label="Eingangsdatum" type="date" value={data.order.received_date} onChange={(v) => setOrder('received_date', v)} />
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<Textarea label="Beschreibung des Auftrags" value={data.order.description} onChange={(v) => setOrder('description', v)}
|
||||
placeholder="z.B. CE-Risikobeurteilung fuer Cobot-Zelle" rows={2} />
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">Umfang</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{SCOPE_OPTIONS.map((s) => {
|
||||
const active = data.order.scope.includes(s)
|
||||
return (
|
||||
<button key={s} type="button" onClick={() => toggleScope(s)}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors ${
|
||||
active ? 'bg-purple-100 border-purple-300 text-purple-700 dark:bg-purple-900/40 dark:border-purple-600 dark:text-purple-300'
|
||||
: 'bg-white border-gray-200 text-gray-600 hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400'
|
||||
}`}>{active ? '\u2713 ' : ''}{s}</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Angebot */}
|
||||
<Card title="Angebot">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Input label="Angebotsdatum" type="date" value={data.offer.date} onChange={(v) => setOffer('date', v)} />
|
||||
<Input label="Angebotsnummer" value={data.offer.number} onChange={(v) => setOffer('number', v)} />
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Angebotssumme</label>
|
||||
<div className="relative">
|
||||
<input type="number" min="0" step="0.01" value={data.offer.amount}
|
||||
onChange={(e) => setOffer('amount', e.target.value)}
|
||||
className="w-full pr-8 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
|
||||
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-sm text-gray-400 pointer-events-none">€</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Status</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<select value={data.offer.status} onChange={(e) => setOffer('status', e.target.value)}
|
||||
className="flex-1 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent">
|
||||
{STATUS_OPTIONS.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
<span className={`px-2.5 py-1 rounded-full text-xs font-medium whitespace-nowrap ${STATUS_COLORS[data.offer.status] || ''}`}>
|
||||
{STATUS_OPTIONS.find((o) => o.value === data.offer.status)?.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{data.offer.status === 'angenommen' && (
|
||||
<div className="mt-4">
|
||||
<Input label="Annahmedatum" type="date" value={data.offer.accepted_date} onChange={(v) => setOffer('accepted_date', v)} />
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Notizen */}
|
||||
<Card title="Notizen">
|
||||
<Textarea value={data.notes} onChange={(v) => update({ ...data, notes: v })}
|
||||
placeholder="Freitext-Notizen zum Auftrag..." rows={4} />
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* --- Shared form primitives --- */
|
||||
|
||||
function Card({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<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">{title}</h2>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Input({ label, value, onChange, type = 'text', placeholder }: {
|
||||
label?: string; value: string; onChange: (v: string) => void; type?: string; placeholder?: string
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
{label && <label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">{label}</label>}
|
||||
<input type={type} value={value} onChange={(e) => onChange(e.target.value)} placeholder={placeholder}
|
||||
className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Textarea({ label, value, onChange, placeholder, rows = 3 }: {
|
||||
label?: string; value: string; onChange: (v: string) => void; placeholder?: string; rows?: number
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
{label && <label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">{label}</label>}
|
||||
<textarea value={value} onChange={(e) => onChange(e.target.value)} placeholder={placeholder} rows={rows}
|
||||
className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent resize-none" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { SuggestedNorms } from './_components/SuggestedNorms'
|
||||
import { ComplianceAlerts } from './_components/ComplianceAlerts'
|
||||
|
||||
interface ProjectOverview {
|
||||
id: string
|
||||
@@ -13,27 +15,19 @@ interface ProjectOverview {
|
||||
completeness_pct: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
gates: Gate[]
|
||||
risk_summary: {
|
||||
critical: number
|
||||
high: number
|
||||
medium: number
|
||||
low: number
|
||||
total: number
|
||||
metadata?: { limits_form?: Record<string, unknown> }
|
||||
risk_summary?: {
|
||||
critical?: number
|
||||
high?: number
|
||||
medium?: number
|
||||
low?: number
|
||||
total?: number
|
||||
}
|
||||
component_count: number
|
||||
hazard_count: number
|
||||
mitigation_count: number
|
||||
}
|
||||
|
||||
interface Gate {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
passed: boolean | null
|
||||
required: boolean
|
||||
}
|
||||
|
||||
const QUICK_ACTIONS = [
|
||||
{ href: '/components', label: 'Komponenten verwalten', icon: 'cube', description: 'SW/FW/AI/HMI Baum bearbeiten' },
|
||||
{ href: '/classification', label: 'Klassifikation pruefen', icon: 'tag', description: 'AI Act, MVO, CRA, NIS2' },
|
||||
@@ -45,33 +39,6 @@ const QUICK_ACTIONS = [
|
||||
{ href: '/monitoring', label: 'Monitoring', icon: 'activity', description: 'Post-Market Ueberwachung' },
|
||||
]
|
||||
|
||||
function GateIndicator({ gate }: { gate: Gate }) {
|
||||
const color = gate.passed === true
|
||||
? 'bg-green-500'
|
||||
: gate.passed === false
|
||||
? 'bg-red-500'
|
||||
: 'bg-gray-300'
|
||||
|
||||
const textColor = gate.passed === true
|
||||
? 'text-green-700'
|
||||
: gate.passed === false
|
||||
? 'text-red-700'
|
||||
: 'text-gray-500'
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 py-2">
|
||||
<div className={`w-3 h-3 rounded-full ${color} flex-shrink-0`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={`text-sm font-medium ${textColor}`}>{gate.name}</div>
|
||||
<div className="text-xs text-gray-400">{gate.description}</div>
|
||||
</div>
|
||||
{gate.required && (
|
||||
<span className="text-xs text-gray-400 flex-shrink-0">Pflicht</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RiskGauge({ label, value, max, color }: { label: string; value: number; max: number; color: string }) {
|
||||
const pct = max > 0 ? Math.round((value / max) * 100) : 0
|
||||
return (
|
||||
@@ -120,11 +87,59 @@ export default function ProjectOverviewPage() {
|
||||
|
||||
async function fetchProject() {
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}`)
|
||||
if (res.ok) {
|
||||
const json = await res.json()
|
||||
setProject(json)
|
||||
// Only fetch project detail + lightweight risk summary (NO heavy lists)
|
||||
const [projRes, riskRes] = await Promise.all([
|
||||
fetch(`/api/sdk/v1/iace/projects/${projectId}`),
|
||||
fetch(`/api/sdk/v1/iace/projects/${projectId}/risk-summary`),
|
||||
])
|
||||
|
||||
if (!projRes.ok) return
|
||||
const json = await projRes.json()
|
||||
|
||||
// Live risk summary from dedicated endpoint (lightweight — just counts)
|
||||
let rs = json.risk_summary || {}
|
||||
let hazCount = 0
|
||||
let mitCount = 0
|
||||
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,
|
||||
}
|
||||
hazCount = live.total_hazards || live.total || 0
|
||||
mitCount = live.total_mitigations || 0
|
||||
}
|
||||
|
||||
// Calculate dynamic completeness percentage from CE process steps
|
||||
const compCount = json.components?.length || 0
|
||||
const limitsForm = json.metadata?.limits_form || {}
|
||||
const hasLimits = Object.keys(limitsForm).length > 0
|
||||
const hasComponents = compCount > 0
|
||||
const hasHazards = hazCount > 0
|
||||
const hasMitigations = mitCount > 0
|
||||
const stepsComplete = [hasLimits, hasComponents, hasHazards, hasMitigations].filter(Boolean).length
|
||||
const completeness = Math.round((stepsComplete / 6) * 100)
|
||||
|
||||
setProject({
|
||||
...json,
|
||||
completeness_pct: completeness,
|
||||
component_count: compCount,
|
||||
hazard_count: hazCount,
|
||||
mitigation_count: mitCount,
|
||||
metadata: json.metadata,
|
||||
risk_summary: {
|
||||
critical: rs.critical || 0,
|
||||
high: rs.high || 0,
|
||||
medium: rs.medium || 0,
|
||||
low: rs.low || 0,
|
||||
total: rs.total || hazCount,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch project:', err)
|
||||
} finally {
|
||||
@@ -229,15 +244,31 @@ export default function ProjectOverviewPage() {
|
||||
</dl>
|
||||
</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">
|
||||
<h2 className="text-sm font-semibold text-gray-900 dark:text-white mb-4">Risikozusammenfassung</h2>
|
||||
<div className="flex items-center justify-around">
|
||||
<RiskGauge label="Kritisch" value={project.risk_summary.critical} max={project.risk_summary.total || 1} color="#EF4444" />
|
||||
<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" />
|
||||
<RiskGauge label="Niedrig" value={project.risk_summary.low} max={project.risk_summary.total || 1} color="#22C55E" />
|
||||
{/* Risk level bars */}
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
{ label: 'Kritisch', value: project.risk_summary?.critical || 0, color: 'bg-red-500', text: 'text-red-700' },
|
||||
{ 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>
|
||||
{/* 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>
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">{project.component_count}</div>
|
||||
@@ -252,21 +283,60 @@ export default function ProjectOverviewPage() {
|
||||
<div className="text-xs text-gray-500">Massnahmen</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>
|
||||
|
||||
{/* Completeness Gates */}
|
||||
{/* Progress Tracker */}
|
||||
<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">Completeness Gates</h2>
|
||||
<div className="space-y-1">
|
||||
{project.gates && project.gates.length > 0 ? (
|
||||
project.gates.map((gate) => <GateIndicator key={gate.id} gate={gate} />)
|
||||
) : (
|
||||
<p className="text-sm text-gray-400">Keine Gates definiert</p>
|
||||
)}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">Projektfortschritt</h2>
|
||||
<span className="text-sm font-bold text-purple-600">{project.completeness_pct}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2.5 mb-4">
|
||||
<div className="bg-purple-500 h-2.5 rounded-full transition-all" style={{ width: `${project.completeness_pct}%` }} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{(() => {
|
||||
const hasLimits = Object.keys(project.metadata?.limits_form || {}).length > 0
|
||||
const steps = [
|
||||
{ done: hasLimits, label: 'Grenzen definiert', detail: hasLimits ? 'Felder ausgefuellt' : 'ausstehend' },
|
||||
{ done: project.component_count > 0, label: 'Komponenten erfasst', detail: `${project.component_count} Komponenten` },
|
||||
{ done: project.hazard_count > 0, label: 'Gefaehrdungen identifiziert', detail: `${project.hazard_count} bewertet` },
|
||||
{ done: project.mitigation_count > 0, label: 'Massnahmen zugeordnet', detail: `${project.mitigation_count} Massnahmen` },
|
||||
{ done: false, label: 'Verifikation', detail: 'ausstehend' },
|
||||
{ done: false, label: 'CE-Akte', detail: 'ausstehend' },
|
||||
]
|
||||
const firstPending = steps.find((s) => !s.done)
|
||||
return (
|
||||
<>
|
||||
{steps.map((step) => (
|
||||
<div key={step.label} className="flex items-center gap-2">
|
||||
<span className={`text-sm flex-shrink-0 ${step.done ? 'text-green-600' : 'text-gray-400'}`}>
|
||||
{step.done ? '\u2713' : '\u25CB'}
|
||||
</span>
|
||||
<span className={`text-sm flex-1 ${step.done ? 'text-gray-900 dark:text-white' : 'text-gray-400'}`}>{step.label}</span>
|
||||
<span className="text-xs text-gray-400">{step.detail}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700 text-xs text-gray-500">
|
||||
Naechster Schritt: {firstPending?.label || 'Alle Schritte abgeschlossen'}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Compliance Alerts */}
|
||||
<ComplianceAlerts projectId={projectId} />
|
||||
|
||||
{/* Suggested Norms */}
|
||||
<SuggestedNorms projectId={projectId} />
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-gray-900 dark:text-white mb-4">Schnellzugriff</h2>
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useCallback } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { ReportPrintView } from './ReportPrintView'
|
||||
import {
|
||||
ReportData, ProjectData, HazardData, MitigationData,
|
||||
NormResult, ComplianceTrigger, RiskSummary,
|
||||
CATEGORY_LABELS, REDUCTION_LABELS, STATUS_LABELS,
|
||||
rpz, plFromRpz, silFromRpz, riskLevelLabel,
|
||||
} from './report-types'
|
||||
|
||||
interface ReportGeneratorProps {
|
||||
projectId: string
|
||||
}
|
||||
|
||||
type ExportStatus = 'idle' | 'loading' | 'ready' | 'error'
|
||||
|
||||
/** Fetches all IACE data and generates PDF (via print) or CSV export. */
|
||||
export function ReportGenerator({ projectId }: ReportGeneratorProps) {
|
||||
const [status, setStatus] = useState<ExportStatus>('idle')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [reportData, setReportData] = useState<ReportData | null>(null)
|
||||
const [showPreview, setShowPreview] = useState(false)
|
||||
|
||||
const fetchAllData = useCallback(async (): Promise<ReportData> => {
|
||||
const base = `/api/sdk/v1/iace/projects/${projectId}`
|
||||
const [projRes, hazRes, mitRes, normRes, trigRes, riskRes] = await Promise.all([
|
||||
fetch(base),
|
||||
fetch(`${base}/hazards`),
|
||||
fetch(`${base}/mitigations`),
|
||||
fetch(`${base}/suggested-norms`),
|
||||
fetch(`${base}/compliance-triggers`),
|
||||
fetch(`${base}/risk-summary`),
|
||||
])
|
||||
|
||||
const project: ProjectData = await projRes.json()
|
||||
const hazJson = await hazRes.json()
|
||||
const hazards: HazardData[] = hazJson.hazards || hazJson || []
|
||||
const mitJson = await mitRes.json()
|
||||
const mitigations: MitigationData[] = mitJson.mitigations || mitJson || []
|
||||
|
||||
let norms: NormResult | null = null
|
||||
if (normRes.ok) {
|
||||
const normJson = await normRes.json()
|
||||
norms = normJson.suggestions || (normJson.a_norms !== undefined ? normJson : null)
|
||||
}
|
||||
|
||||
let triggers: ComplianceTrigger[] = []
|
||||
if (trigRes.ok) {
|
||||
const trigJson = await trigRes.json()
|
||||
triggers = trigJson.triggers || trigJson || []
|
||||
}
|
||||
|
||||
let riskSummary: RiskSummary = {}
|
||||
if (riskRes.ok) {
|
||||
const riskJson = await riskRes.json()
|
||||
riskSummary = riskJson.summary || riskJson || {}
|
||||
}
|
||||
|
||||
return { project, hazards, mitigations, norms, triggers, riskSummary }
|
||||
}, [projectId])
|
||||
|
||||
async function handlePdfExport() {
|
||||
setStatus('loading')
|
||||
setError(null)
|
||||
try {
|
||||
const data = await fetchAllData()
|
||||
setReportData(data)
|
||||
setShowPreview(true)
|
||||
setStatus('ready')
|
||||
// Print is triggered after the preview renders
|
||||
setTimeout(() => triggerPrint(data), 300)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Export fehlgeschlagen')
|
||||
setStatus('error')
|
||||
}
|
||||
}
|
||||
|
||||
function triggerPrint(data: ReportData) {
|
||||
const printWindow = window.open('', '_blank', 'width=900,height=700')
|
||||
if (!printWindow) {
|
||||
// Popup blocked — fall back to preview
|
||||
setShowPreview(true)
|
||||
return
|
||||
}
|
||||
printWindow.document.write('<!DOCTYPE html><html><head><title>CE-Akte - ')
|
||||
printWindow.document.write(data.project.machine_name)
|
||||
printWindow.document.write('</title></head><body>')
|
||||
printWindow.document.write('<div id="report-root"></div></body></html>')
|
||||
printWindow.document.close()
|
||||
|
||||
// Render the report into the print window
|
||||
const root = printWindow.document.getElementById('report-root')
|
||||
if (root) {
|
||||
// Use createRoot from react-dom/client
|
||||
import('react-dom/client').then(({ createRoot }) => {
|
||||
const reactRoot = createRoot(root)
|
||||
reactRoot.render(<ReportPrintView data={data} />)
|
||||
// Wait for render, then print
|
||||
setTimeout(() => {
|
||||
printWindow.print()
|
||||
}, 500)
|
||||
})
|
||||
}
|
||||
setShowPreview(false)
|
||||
}
|
||||
|
||||
async function handleCsvExport() {
|
||||
setStatus('loading')
|
||||
setError(null)
|
||||
try {
|
||||
const data = await fetchAllData()
|
||||
generateCsvDownload(data)
|
||||
setStatus('idle')
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Export fehlgeschlagen')
|
||||
setStatus('error')
|
||||
}
|
||||
}
|
||||
|
||||
function generateCsvDownload(data: ReportData) {
|
||||
const { project, hazards, mitigations, norms, triggers, riskSummary } = data
|
||||
const lines: string[] = []
|
||||
const sep = '\t'
|
||||
|
||||
// Sheet 1: Projekt
|
||||
lines.push('=== PROJEKT ===')
|
||||
lines.push(['Feld', 'Wert'].join(sep))
|
||||
lines.push(['Maschinenname', project.machine_name].join(sep))
|
||||
lines.push(['Maschinentyp', project.machine_type || ''].join(sep))
|
||||
lines.push(['Hersteller', project.manufacturer || ''].join(sep))
|
||||
lines.push(['Status', project.status].join(sep))
|
||||
lines.push(['Vollstaendigkeit', `${project.completeness_pct}%`].join(sep))
|
||||
lines.push(['Erstellt', project.created_at].join(sep))
|
||||
lines.push(['Aktualisiert', project.updated_at].join(sep))
|
||||
lines.push('')
|
||||
|
||||
// Sheet 2: Gefaehrdungen
|
||||
lines.push('=== GEFAEHRDUNGSLISTE ===')
|
||||
lines.push(['Nr', 'Gefaehrdung', 'Komponente', 'Kategorie', 'Szenario',
|
||||
'Lebensphase', 'S', 'E', 'P', 'A', 'RPZ', 'SIL', 'PL', 'Risiko'].join(sep))
|
||||
const sorted = [...hazards].sort((a, b) => (b.r_inherent || 0) - (a.r_inherent || 0))
|
||||
sorted.forEach((h, i) => {
|
||||
const r = rpz(h.severity, h.exposure, h.probability, h.avoidance)
|
||||
lines.push([
|
||||
String(i + 1), esc(h.name), esc(h.component_name || ''),
|
||||
CATEGORY_LABELS[h.category] || h.category,
|
||||
esc(h.possible_harm || h.trigger_event || ''),
|
||||
h.lifecycle_phase || '',
|
||||
String(h.severity), String(h.exposure), String(h.probability), String(h.avoidance),
|
||||
String(r), String(silFromRpz(r)), plFromRpz(r), riskLevelLabel(h.risk_level),
|
||||
].join(sep))
|
||||
})
|
||||
lines.push('')
|
||||
|
||||
// Sheet 3: Massnahmen
|
||||
lines.push('=== MASSNAHMENLISTE ===')
|
||||
lines.push(['Nr', 'Massnahme', 'Beschreibung', 'Typ', 'Zugeordnete Gefaehrdungen', 'Status'].join(sep))
|
||||
mitigations.forEach((m, i) => {
|
||||
lines.push([
|
||||
String(i + 1), esc(m.title), esc(m.description),
|
||||
REDUCTION_LABELS[m.reduction_type] || m.reduction_type,
|
||||
esc(m.linked_hazard_names?.join('; ') || ''),
|
||||
STATUS_LABELS[m.status] || m.status,
|
||||
].join(sep))
|
||||
})
|
||||
lines.push('')
|
||||
|
||||
// Sheet 4: Normen
|
||||
if (norms && norms.total > 0) {
|
||||
lines.push('=== ANGEWANDTE NORMEN ===')
|
||||
lines.push(['Typ', 'Nummer', 'Titel', 'Grund'].join(sep))
|
||||
for (const key of ['a_norms', 'b1_norms', 'b2_norms', 'c_norms'] as const) {
|
||||
for (const ns of norms[key]) {
|
||||
lines.push([key, ns.norm.number, esc(ns.norm.title_de), esc(ns.reason)].join(sep))
|
||||
}
|
||||
}
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
// Sheet 5: Compliance Triggers
|
||||
if (triggers.length > 0) {
|
||||
lines.push('=== COMPLIANCE-HINWEISE ===')
|
||||
lines.push(['Regulation', 'Artikel', 'Titel', 'Schwere', 'Grund'].join(sep))
|
||||
triggers.forEach(t => {
|
||||
lines.push([t.regulation, t.article, esc(t.title), t.severity, esc(t.reason)].join(sep))
|
||||
})
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
// Sheet 6: Risikozusammenfassung
|
||||
lines.push('=== RISIKOZUSAMMENFASSUNG ===')
|
||||
lines.push(['Stufe', 'Anzahl'].join(sep))
|
||||
lines.push(['Kritisch', String(riskSummary.critical || 0)].join(sep))
|
||||
lines.push(['Hoch', String(riskSummary.high || 0)].join(sep))
|
||||
lines.push(['Mittel', String(riskSummary.medium || 0)].join(sep))
|
||||
lines.push(['Niedrig', String(riskSummary.low || 0)].join(sep))
|
||||
|
||||
// BOM for Excel to recognize UTF-8
|
||||
const bom = '\uFEFF'
|
||||
const blob = new Blob([bom + lines.join('\n')], { type: 'text/tab-separated-values;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `CE-Akte-${project.machine_name.replace(/[^a-zA-Z0-9_-]/g, '_')}.tsv`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handlePdfExport}
|
||||
disabled={status === 'loading'}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors disabled:opacity-50 text-sm font-medium"
|
||||
>
|
||||
{status === 'loading' ? (
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white" />
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||
</svg>
|
||||
)}
|
||||
PDF exportieren
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleCsvExport}
|
||||
disabled={status === 'loading'}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50 text-sm font-medium"
|
||||
>
|
||||
{status === 'loading' ? (
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white" />
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
)}
|
||||
Excel exportieren
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-red-600 mt-1">{error}</p>
|
||||
)}
|
||||
|
||||
{/* Hidden preview portal for fallback printing */}
|
||||
{showPreview && reportData && typeof document !== 'undefined' && createPortal(
|
||||
<div
|
||||
className="fixed inset-0 bg-white z-[9999] overflow-auto print:static"
|
||||
style={{ padding: '20px' }}
|
||||
>
|
||||
<div className="print:hidden flex items-center gap-3 mb-4 p-4 bg-gray-100 rounded-lg">
|
||||
<button
|
||||
onClick={() => window.print()}
|
||||
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 text-sm font-medium"
|
||||
>
|
||||
Drucken / Als PDF speichern
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowPreview(false)}
|
||||
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 text-sm"
|
||||
>
|
||||
Schliessen
|
||||
</button>
|
||||
<span className="text-xs text-gray-500">
|
||||
Popup wurde blockiert. Nutzen Sie die Druckfunktion Ihres Browsers (Strg+P).
|
||||
</span>
|
||||
</div>
|
||||
<ReportPrintView data={reportData} />
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** Escape tab and newline for TSV. */
|
||||
function esc(s: string): string {
|
||||
return s.replace(/[\t\n\r]/g, ' ')
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import {
|
||||
ReportData, rpz, plFromRpz, silFromRpz, riskLevelLabel, riskLevelColor,
|
||||
CATEGORY_LABELS, REDUCTION_LABELS, STATUS_LABELS,
|
||||
} from './report-types'
|
||||
|
||||
interface ReportPrintViewProps {
|
||||
data: ReportData
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
try { return new Date(iso).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }) }
|
||||
catch { return iso }
|
||||
}
|
||||
|
||||
const NORM_TYPE_LABELS: Record<string, string> = {
|
||||
a_norms: 'A-Normen (Grundnormen)',
|
||||
b1_norms: 'B1-Normen (Sicherheitsgrundnormen)',
|
||||
b2_norms: 'B2-Normen (Sicherheitsfachgrundnormen)',
|
||||
c_norms: 'C-Normen (Maschinenspezifisch)',
|
||||
}
|
||||
|
||||
/** Print-optimized CE report rendered as HTML for window.print(). */
|
||||
export function ReportPrintView({ data }: ReportPrintViewProps) {
|
||||
const { project, hazards, mitigations, norms, triggers, riskSummary } = data
|
||||
const sortedHazards = [...hazards].sort((a, b) => (b.r_inherent || 0) - (a.r_inherent || 0))
|
||||
const byDesign = mitigations.filter(m => m.reduction_type === 'design')
|
||||
const byProtection = mitigations.filter(m => m.reduction_type === 'protection')
|
||||
const byInfo = mitigations.filter(m => m.reduction_type === 'information')
|
||||
const openMitigations = mitigations.filter(m => m.status !== 'verified')
|
||||
const highRiskCount = (riskSummary.critical || 0) + (riskSummary.high || 0)
|
||||
|
||||
return (
|
||||
<div className="report-print-view">
|
||||
<style>{`
|
||||
.report-print-view {
|
||||
font-family: 'Segoe UI', Arial, sans-serif;
|
||||
color: #1a1a1a;
|
||||
font-size: 10pt;
|
||||
line-height: 1.5;
|
||||
max-width: 210mm;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.report-print-view h1 { font-size: 20pt; margin: 0 0 4pt; color: #1e1b4b; }
|
||||
.report-print-view h2 {
|
||||
font-size: 13pt; margin: 20pt 0 8pt; padding-bottom: 4pt;
|
||||
border-bottom: 2pt solid #7c3aed; color: #1e1b4b;
|
||||
}
|
||||
.report-print-view h3 { font-size: 11pt; margin: 12pt 0 6pt; color: #374151; }
|
||||
.report-print-view table {
|
||||
width: 100%; border-collapse: collapse; margin: 8pt 0;
|
||||
font-size: 8.5pt; page-break-inside: auto;
|
||||
}
|
||||
.report-print-view th, .report-print-view td {
|
||||
border: 0.5pt solid #d1d5db; padding: 3pt 5pt; text-align: left;
|
||||
}
|
||||
.report-print-view th {
|
||||
background: #f3f4f6; font-weight: 600; color: #374151;
|
||||
}
|
||||
.report-print-view tr { page-break-inside: avoid; }
|
||||
.report-print-view .cover {
|
||||
text-align: center; padding: 60pt 20pt 40pt;
|
||||
border-bottom: 3pt solid #7c3aed;
|
||||
}
|
||||
.report-print-view .cover .subtitle {
|
||||
font-size: 14pt; color: #6b7280; margin-top: 8pt;
|
||||
}
|
||||
.report-print-view .cover .meta {
|
||||
margin-top: 30pt; font-size: 10pt; color: #374151;
|
||||
}
|
||||
.report-print-view .cover .meta td { border: none; padding: 2pt 8pt; }
|
||||
.report-print-view .cover .meta td:first-child { font-weight: 600; text-align: right; }
|
||||
.report-print-view .toc { margin: 16pt 0; }
|
||||
.report-print-view .toc li { padding: 3pt 0; color: #374151; }
|
||||
.report-print-view .risk-cell { font-weight: 600; text-align: center; }
|
||||
.report-print-view .badge {
|
||||
display: inline-block; padding: 1pt 6pt; border-radius: 3pt;
|
||||
font-size: 7.5pt; font-weight: 600;
|
||||
}
|
||||
.report-print-view .section-break { page-break-before: always; }
|
||||
.report-print-view .summary-box {
|
||||
border: 1pt solid #d1d5db; border-radius: 4pt; padding: 12pt;
|
||||
margin: 8pt 0; background: #f9fafb;
|
||||
}
|
||||
.report-print-view .footer-line {
|
||||
margin-top: 24pt; padding-top: 8pt; border-top: 1pt solid #d1d5db;
|
||||
font-size: 8pt; color: #9ca3af; text-align: center;
|
||||
}
|
||||
@media print {
|
||||
.report-print-view { margin: 0; max-width: none; }
|
||||
.report-print-view .section-break { page-break-before: always; }
|
||||
@page { size: A4; margin: 15mm 12mm 18mm; }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{/* 1. Deckblatt */}
|
||||
<div className="cover">
|
||||
<h1>CE-Akte / Risikobeurteilung</h1>
|
||||
<div className="subtitle">{project.machine_name}</div>
|
||||
<table className="meta" style={{ margin: '30pt auto 0', textAlign: 'left' }}>
|
||||
<tbody>
|
||||
<tr><td>Maschinentyp:</td><td>{project.machine_type || '-'}</td></tr>
|
||||
<tr><td>Hersteller:</td><td>{project.manufacturer || '-'}</td></tr>
|
||||
<tr><td>Projektstatus:</td><td>{project.status}</td></tr>
|
||||
<tr><td>Erstelldatum:</td><td>{formatDate(project.created_at)}</td></tr>
|
||||
<tr><td>Letzte Aktualisierung:</td><td>{formatDate(project.updated_at)}</td></tr>
|
||||
<tr><td>Vollstaendigkeit:</td><td>{project.completeness_pct}%</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 2. Inhaltsverzeichnis */}
|
||||
<div className="section-break">
|
||||
<h2>Inhaltsverzeichnis</h2>
|
||||
<ol className="toc">
|
||||
<li>Maschinenbeschreibung</li>
|
||||
<li>Angewandte Normen</li>
|
||||
<li>Gefaehrdungsliste</li>
|
||||
<li>Risikobewertung</li>
|
||||
<li>Massnahmenliste</li>
|
||||
<li>Compliance-Hinweise</li>
|
||||
<li>Zusammenfassung</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
{/* 3. Maschinenbeschreibung */}
|
||||
<div className="section-break">
|
||||
<h2>1. Maschinenbeschreibung</h2>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr><td style={{ fontWeight: 600, width: '35%' }}>Maschinenbezeichnung</td><td>{project.machine_name}</td></tr>
|
||||
<tr><td style={{ fontWeight: 600 }}>Maschinentyp</td><td>{project.machine_type || '-'}</td></tr>
|
||||
<tr><td style={{ fontWeight: 600 }}>Hersteller</td><td>{project.manufacturer || '-'}</td></tr>
|
||||
<tr><td style={{ fontWeight: 600 }}>Anzahl Komponenten</td><td>{project.component_count}</td></tr>
|
||||
<tr><td style={{ fontWeight: 600 }}>Anzahl Gefaehrdungen</td><td>{project.hazard_count}</td></tr>
|
||||
<tr><td style={{ fontWeight: 600 }}>Anzahl Massnahmen</td><td>{project.mitigation_count}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 4. Angewandte Normen */}
|
||||
<div className="section-break">
|
||||
<h2>2. Angewandte Normen</h2>
|
||||
{norms && norms.total > 0 ? (
|
||||
(['a_norms', 'b1_norms', 'b2_norms', 'c_norms'] as const).map(key => {
|
||||
const items = norms[key]
|
||||
if (!items || items.length === 0) return null
|
||||
return (
|
||||
<div key={key}>
|
||||
<h3>{NORM_TYPE_LABELS[key]}</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th style={{ width: '20%' }}>Nummer</th><th style={{ width: '45%' }}>Titel</th><th>Abschnitte / Grund</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((ns, i) => (
|
||||
<tr key={i}>
|
||||
<td>{ns.norm.number}</td>
|
||||
<td>{ns.norm.title_de}</td>
|
||||
<td>{ns.reason}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<p style={{ color: '#6b7280' }}>Keine Normenvorschlaege vorhanden.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 5. Gefaehrdungsliste */}
|
||||
<div className="section-break">
|
||||
<h2>3. Gefaehrdungsliste</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '5%' }}>Nr.</th>
|
||||
<th style={{ width: '15%' }}>Komponente</th>
|
||||
<th style={{ width: '20%' }}>Gefaehrdung</th>
|
||||
<th style={{ width: '12%' }}>Kategorie</th>
|
||||
<th style={{ width: '24%' }}>Szenario</th>
|
||||
<th style={{ width: '12%' }}>Lebensphase</th>
|
||||
<th style={{ width: '12%' }}>Risiko</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedHazards.map((h, i) => (
|
||||
<tr key={h.id}>
|
||||
<td>{i + 1}</td>
|
||||
<td>{h.component_name || '-'}</td>
|
||||
<td>{h.name}</td>
|
||||
<td>{CATEGORY_LABELS[h.category] || h.category}</td>
|
||||
<td>{h.possible_harm || h.trigger_event || '-'}</td>
|
||||
<td>{h.lifecycle_phase || '-'}</td>
|
||||
<td className="risk-cell" style={{ color: riskLevelColor(h.risk_level) }}>
|
||||
{riskLevelLabel(h.risk_level)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 6. Risikobewertung */}
|
||||
<div className="section-break">
|
||||
<h2>4. Risikobewertung</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nr.</th>
|
||||
<th>Gefaehrdung</th>
|
||||
<th style={{ textAlign: 'center' }}>S</th>
|
||||
<th style={{ textAlign: 'center' }}>E</th>
|
||||
<th style={{ textAlign: 'center' }}>P</th>
|
||||
<th style={{ textAlign: 'center' }}>RPZ</th>
|
||||
<th style={{ textAlign: 'center' }}>SIL</th>
|
||||
<th style={{ textAlign: 'center' }}>PL</th>
|
||||
<th>Risiko</th>
|
||||
<th style={{ textAlign: 'center' }}>Akzeptabel</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedHazards.map((h, i) => {
|
||||
const r = rpz(h.severity, h.exposure, h.probability, h.avoidance)
|
||||
const sil = silFromRpz(r)
|
||||
const pl = plFromRpz(r)
|
||||
const acceptable = r <= 20
|
||||
return (
|
||||
<tr key={h.id}>
|
||||
<td>{i + 1}</td>
|
||||
<td>{h.name}</td>
|
||||
<td style={{ textAlign: 'center' }}>{h.severity}</td>
|
||||
<td style={{ textAlign: 'center' }}>{h.exposure}</td>
|
||||
<td style={{ textAlign: 'center' }}>{h.probability}</td>
|
||||
<td className="risk-cell" style={{ color: riskLevelColor(h.risk_level) }}>{r}</td>
|
||||
<td style={{ textAlign: 'center' }}>{sil}</td>
|
||||
<td style={{ textAlign: 'center' }}>{pl}</td>
|
||||
<td style={{ color: riskLevelColor(h.risk_level) }}>{riskLevelLabel(h.risk_level)}</td>
|
||||
<td style={{ textAlign: 'center', color: acceptable ? '#16a34a' : '#dc2626', fontWeight: 600 }}>
|
||||
{acceptable ? 'Ja' : 'Nein'}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 7. Massnahmenliste */}
|
||||
<div className="section-break">
|
||||
<h2>5. Massnahmenliste</h2>
|
||||
<p style={{ marginBottom: '8pt', color: '#374151' }}>
|
||||
Gesamt: {mitigations.length} Massnahmen
|
||||
(Design: {byDesign.length}, Schutz: {byProtection.length}, Information: {byInfo.length})
|
||||
</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '5%' }}>Nr.</th>
|
||||
<th style={{ width: '25%' }}>Massnahme</th>
|
||||
<th style={{ width: '15%' }}>Typ</th>
|
||||
<th style={{ width: '30%' }}>Zugeordnete Gefaehrdungen</th>
|
||||
<th style={{ width: '12%' }}>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{mitigations.map((m, i) => (
|
||||
<tr key={m.id}>
|
||||
<td>{i + 1}</td>
|
||||
<td>{m.title}</td>
|
||||
<td>{REDUCTION_LABELS[m.reduction_type] || m.reduction_type}</td>
|
||||
<td>{m.linked_hazard_names?.join(', ') || '-'}</td>
|
||||
<td>
|
||||
<span className="badge" style={{
|
||||
background: m.status === 'verified' ? '#dcfce7' : m.status === 'implemented' ? '#dbeafe' : '#fef3c7',
|
||||
color: m.status === 'verified' ? '#166534' : m.status === 'implemented' ? '#1e40af' : '#92400e',
|
||||
}}>
|
||||
{STATUS_LABELS[m.status] || m.status}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 8. Compliance-Hinweise */}
|
||||
{triggers.length > 0 && (
|
||||
<div className="section-break">
|
||||
<h2>6. Compliance-Hinweise</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '12%' }}>Regulation</th>
|
||||
<th style={{ width: '12%' }}>Artikel</th>
|
||||
<th style={{ width: '25%' }}>Titel</th>
|
||||
<th style={{ width: '10%' }}>Schwere</th>
|
||||
<th>Grund</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{triggers.map((t) => (
|
||||
<tr key={t.id}>
|
||||
<td>{t.regulation}</td>
|
||||
<td>{t.article}</td>
|
||||
<td>{t.title}</td>
|
||||
<td>
|
||||
<span className="badge" style={{
|
||||
background: t.severity === 'high' ? '#fecaca' : t.severity === 'medium' ? '#fef3c7' : '#dbeafe',
|
||||
color: t.severity === 'high' ? '#991b1b' : t.severity === 'medium' ? '#92400e' : '#1e40af',
|
||||
}}>
|
||||
{t.severity === 'high' ? 'HOCH' : t.severity === 'medium' ? 'MITTEL' : 'NIEDRIG'}
|
||||
</span>
|
||||
</td>
|
||||
<td>{t.reason}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 9. Zusammenfassung */}
|
||||
<div className="section-break">
|
||||
<h2>7. Zusammenfassung</h2>
|
||||
<div className="summary-box">
|
||||
<h3 style={{ marginTop: 0 }}>Gesamtrisiko</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Risikostufe</th>
|
||||
<th style={{ textAlign: 'center' }}>Anzahl</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[
|
||||
['Kritisch / Sehr hoch', (riskSummary.critical || 0) + (riskSummary.high || 0), '#dc2626'],
|
||||
['Mittel', riskSummary.medium || 0, '#ca8a04'],
|
||||
['Niedrig', riskSummary.low || 0, '#16a34a'],
|
||||
].map(([label, count, color]) => (
|
||||
<tr key={String(label)}>
|
||||
<td style={{ color: String(color), fontWeight: 600 }}>{String(label)}</td>
|
||||
<td style={{ textAlign: 'center' }}>{String(count)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="summary-box">
|
||||
<h3 style={{ marginTop: 0 }}>Offene Massnahmen</h3>
|
||||
<p>{openMitigations.length} von {mitigations.length} Massnahmen noch nicht verifiziert.</p>
|
||||
</div>
|
||||
|
||||
<div className="summary-box">
|
||||
<h3 style={{ marginTop: 0 }}>Empfehlung</h3>
|
||||
<p style={{ fontWeight: 600, color: highRiskCount > 0 ? '#dc2626' : '#16a34a' }}>
|
||||
{highRiskCount > 0
|
||||
? `Es bestehen ${highRiskCount} Gefaehrdungen mit hohem/kritischem Risiko. Massnahmen muessen umgesetzt und verifiziert werden, bevor die Maschine in Verkehr gebracht werden darf.`
|
||||
: openMitigations.length > 0
|
||||
? 'Alle identifizierten Risiken liegen im akzeptablen Bereich. Offene Massnahmen sollten zeitnah abgeschlossen und verifiziert werden.'
|
||||
: 'Alle Risiken liegen im akzeptablen Bereich und alle Massnahmen sind verifiziert. Die Maschine kann in Verkehr gebracht werden.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="footer-line">
|
||||
Erstellt mit BreakPilot ComplAI am {formatDate(new Date().toISOString())} | CE-Akte: {project.machine_name}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
// Types shared between ReportGenerator and ReportPrintView
|
||||
|
||||
export interface ProjectData {
|
||||
id: string
|
||||
machine_name: string
|
||||
machine_type: string
|
||||
manufacturer: string
|
||||
status: string
|
||||
completeness_pct: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
component_count: number
|
||||
hazard_count: number
|
||||
mitigation_count: number
|
||||
}
|
||||
|
||||
export interface HazardData {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
component_name: string | null
|
||||
category: string
|
||||
lifecycle_phase: string
|
||||
trigger_event: string
|
||||
affected_person: string
|
||||
possible_harm: string
|
||||
severity: number
|
||||
exposure: number
|
||||
probability: number
|
||||
avoidance: number
|
||||
r_inherent: number
|
||||
risk_level: string
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface MitigationData {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
reduction_type: 'design' | 'protection' | 'information'
|
||||
status: 'planned' | 'implemented' | 'verified'
|
||||
linked_hazard_ids: string[]
|
||||
linked_hazard_names: string[]
|
||||
}
|
||||
|
||||
export interface NormSuggestion {
|
||||
norm: {
|
||||
id: string
|
||||
number: string
|
||||
title_de: string
|
||||
norm_type: string
|
||||
scope_de: string
|
||||
mandatory: boolean
|
||||
}
|
||||
reason: string
|
||||
confidence: number
|
||||
}
|
||||
|
||||
export interface NormResult {
|
||||
a_norms: NormSuggestion[]
|
||||
b1_norms: NormSuggestion[]
|
||||
b2_norms: NormSuggestion[]
|
||||
c_norms: NormSuggestion[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface ComplianceTrigger {
|
||||
id: string
|
||||
regulation: string
|
||||
article: string
|
||||
title: string
|
||||
severity: 'high' | 'medium' | 'low'
|
||||
reason: string
|
||||
affected_hazard_count?: number
|
||||
module_path: string
|
||||
module_label: string
|
||||
}
|
||||
|
||||
export interface RiskSummary {
|
||||
critical?: number
|
||||
high?: number
|
||||
medium?: number
|
||||
low?: number
|
||||
total?: number
|
||||
}
|
||||
|
||||
export interface ReportData {
|
||||
project: ProjectData
|
||||
hazards: HazardData[]
|
||||
mitigations: MitigationData[]
|
||||
norms: NormResult | null
|
||||
triggers: ComplianceTrigger[]
|
||||
riskSummary: RiskSummary
|
||||
}
|
||||
|
||||
// Helpers shared by report views
|
||||
|
||||
export function rpz(s: number, e: number, p: number, a: number): number {
|
||||
return a >= 1 ? s * e * p * a : s * e * p
|
||||
}
|
||||
|
||||
export 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'
|
||||
}
|
||||
|
||||
export function silFromRpz(r: number): number {
|
||||
if (r > 300) return 3
|
||||
if (r >= 151) return 2
|
||||
if (r >= 61) return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
export function riskLevelLabel(level: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
not_acceptable: 'Nicht akzeptabel',
|
||||
very_high: 'Sehr hoch',
|
||||
critical: 'Kritisch',
|
||||
high: 'Hoch',
|
||||
medium: 'Mittel',
|
||||
low: 'Niedrig',
|
||||
}
|
||||
return labels[level] || level
|
||||
}
|
||||
|
||||
export function riskLevelColor(level: string): string {
|
||||
const colors: Record<string, string> = {
|
||||
not_acceptable: '#dc2626',
|
||||
very_high: '#dc2626',
|
||||
critical: '#dc2626',
|
||||
high: '#ea580c',
|
||||
medium: '#ca8a04',
|
||||
low: '#16a34a',
|
||||
}
|
||||
return colors[level] || '#6b7280'
|
||||
}
|
||||
|
||||
export const CATEGORY_LABELS: Record<string, string> = {
|
||||
mechanical: 'Mechanisch',
|
||||
electrical: 'Elektrisch',
|
||||
thermal: 'Thermisch',
|
||||
pneumatic_hydraulic: 'Pneumatik/Hydraulik',
|
||||
noise_vibration: 'Laerm/Vibration',
|
||||
ergonomic: 'Ergonomie',
|
||||
material_environmental: 'Stoffe/Umwelt',
|
||||
software_control: 'Software/Steuerung',
|
||||
cyber_network: 'Cyber/Netzwerk',
|
||||
ai_specific: 'KI-spezifisch',
|
||||
}
|
||||
|
||||
export const REDUCTION_LABELS: Record<string, string> = {
|
||||
design: 'Stufe 1: Design',
|
||||
protection: 'Stufe 2: Schutz',
|
||||
information: 'Stufe 3: Information',
|
||||
}
|
||||
|
||||
export const STATUS_LABELS: Record<string, string> = {
|
||||
planned: 'Geplant',
|
||||
implemented: 'Umgesetzt',
|
||||
verified: 'Verifiziert',
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* CE-Akte section type metadata — icons and descriptions for the tech-file viewer.
|
||||
* Structured per EU Machinery Regulation 2023/1230 Annex IV.
|
||||
*/
|
||||
|
||||
export const SECTION_TYPES: Record<string, { icon: string; description: string }> = {
|
||||
// Annex IV mandatory sections (EU Machinery Regulation 2023/1230)
|
||||
general_description: {
|
||||
icon: '🏭',
|
||||
description: 'Anhang IV.1 — Allgemeine Beschreibung der Maschine mit bestimmungsgemaesser Verwendung',
|
||||
},
|
||||
design_specifications: {
|
||||
icon: '📐',
|
||||
description: 'Anhang IV.2 — Gesamtplan, Schaltplaene und Systemarchitektur',
|
||||
},
|
||||
component_list: {
|
||||
icon: '🔧',
|
||||
description: 'Anhang IV.3 — Detailplaene und Verzeichnis aller sicherheitsrelevanten Komponenten',
|
||||
},
|
||||
risk_assessment_report: {
|
||||
icon: '📊',
|
||||
description: 'Anhang IV.4 — Risikobeurteilung nach ISO 12100 mit allen bewerteten Gefaehrdungen',
|
||||
},
|
||||
standards_applied: {
|
||||
icon: '📏',
|
||||
description: 'Anhang IV.5 — Angewandte harmonisierte Normen und deren Vermutungswirkung',
|
||||
},
|
||||
test_reports: {
|
||||
icon: '🧪',
|
||||
description: 'Anhang IV.6 — Pruefberichte und Verifikationsergebnisse',
|
||||
},
|
||||
instructions_for_use: {
|
||||
icon: '📖',
|
||||
description: 'Anhang IV.7 — Betriebsanleitung mit Sicherheitshinweisen',
|
||||
},
|
||||
declaration_of_conformity: {
|
||||
icon: '📜',
|
||||
description: 'Anhang IV.8 — EU-Konformitaetserklaerung',
|
||||
},
|
||||
assembly_declaration: {
|
||||
icon: '🔩',
|
||||
description: 'Anhang IV.9 — Einbauerklaerung fuer unvollstaendige Maschinen',
|
||||
},
|
||||
// Supplementary CE-Akte sections
|
||||
hazard_log_combined: {
|
||||
icon: '⚠️',
|
||||
description: 'Vollstaendiges Gefaehrdungsprotokoll (Hazard Log) mit S/E/P-Bewertungen',
|
||||
},
|
||||
essential_requirements: {
|
||||
icon: '📋',
|
||||
description: 'Grundlegende Anforderungen (EHSR) nach MVO Anhang III',
|
||||
},
|
||||
mitigation_report: {
|
||||
icon: '🛡️',
|
||||
description: 'Uebersicht aller Schutzmassnahmen nach 3-Stufen-Verfahren',
|
||||
},
|
||||
verification_report: {
|
||||
icon: '✅',
|
||||
description: 'Verifikationsplan und Ergebnisse aller Nachweisverfahren',
|
||||
},
|
||||
evidence_index: {
|
||||
icon: '📎',
|
||||
description: 'Index aller Nachweisdokumente mit Verknuepfungen',
|
||||
},
|
||||
classification_report: {
|
||||
icon: '🏷️',
|
||||
description: 'Regulatorische Klassifikation (AI Act, MVO, CRA, NIS2)',
|
||||
},
|
||||
monitoring_plan: {
|
||||
icon: '📡',
|
||||
description: 'Post-Market Surveillance und Ueberwachungsplan',
|
||||
},
|
||||
// AI-specific sections (when AI components present)
|
||||
ai_intended_purpose: {
|
||||
icon: '🎯',
|
||||
description: 'Bestimmungsgemaesser Zweck des KI-Systems (AI Act Art. 13)',
|
||||
},
|
||||
ai_model_description: {
|
||||
icon: '🧠',
|
||||
description: 'KI-Modellbeschreibung, Trainingsdaten und Architektur',
|
||||
},
|
||||
ai_risk_management: {
|
||||
icon: '⚙️',
|
||||
description: 'KI-Risikomanagementsystem (AI Act Art. 9)',
|
||||
},
|
||||
ai_human_oversight: {
|
||||
icon: '👁️',
|
||||
description: 'Menschliche Aufsicht und Kontrollmassnahmen (AI Act Art. 14)',
|
||||
},
|
||||
}
|
||||
|
||||
export const STATUS_CONFIG: Record<string, { label: string; color: string; bgColor: string }> = {
|
||||
empty: { label: 'Leer', color: 'text-gray-500', bgColor: 'bg-gray-100' },
|
||||
draft: { label: 'Entwurf', color: 'text-yellow-700', bgColor: 'bg-yellow-100' },
|
||||
generated: { label: 'Generiert', color: 'text-blue-700', bgColor: 'bg-blue-100' },
|
||||
reviewed: { label: 'Geprueft', color: 'text-orange-700', bgColor: 'bg-orange-100' },
|
||||
approved: { label: 'Freigegeben', color: 'text-green-700', bgColor: 'bg-green-100' },
|
||||
}
|
||||
|
||||
export const EXPORT_FORMATS: { value: string; label: string; extension: string }[] = [
|
||||
{ value: 'pdf', label: 'PDF', extension: '.pdf' },
|
||||
{ value: 'xlsx', label: 'Excel', extension: '.xlsx' },
|
||||
{ value: 'docx', label: 'Word', extension: '.docx' },
|
||||
{ value: 'md', label: 'Markdown', extension: '.md' },
|
||||
{ value: 'json', label: 'JSON', extension: '.json' },
|
||||
]
|
||||
@@ -3,6 +3,8 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { TechFileEditor } from '@/components/sdk/iace/TechFileEditor'
|
||||
import { ReportGenerator } from './_components/ReportGenerator'
|
||||
import { SECTION_TYPES, STATUS_CONFIG, EXPORT_FORMATS } from './_constants'
|
||||
|
||||
interface TechFileSection {
|
||||
id: string
|
||||
@@ -17,65 +19,6 @@ interface TechFileSection {
|
||||
required: boolean
|
||||
}
|
||||
|
||||
const SECTION_TYPES: Record<string, { icon: string; description: string }> = {
|
||||
risk_assessment_report: {
|
||||
icon: '📊',
|
||||
description: 'Zusammenfassung der Risikobeurteilung mit allen bewerteten Gefaehrdungen',
|
||||
},
|
||||
hazard_log: {
|
||||
icon: '⚠️',
|
||||
description: 'Vollstaendiges Gefaehrdungsprotokoll mit S/E/P-Bewertungen',
|
||||
},
|
||||
component_list: {
|
||||
icon: '🔧',
|
||||
description: 'Verzeichnis aller sicherheitsrelevanten Komponenten',
|
||||
},
|
||||
classification_report: {
|
||||
icon: '📋',
|
||||
description: 'Regulatorische Klassifikation (AI Act, MVO, CRA, NIS2)',
|
||||
},
|
||||
mitigation_report: {
|
||||
icon: '🛡️',
|
||||
description: 'Uebersicht aller Schutzmassnahmen nach 3-Stufen-Verfahren',
|
||||
},
|
||||
verification_report: {
|
||||
icon: '✅',
|
||||
description: 'Verifikationsplan und Ergebnisse aller Nachweisverfahren',
|
||||
},
|
||||
evidence_index: {
|
||||
icon: '📎',
|
||||
description: 'Index aller Nachweisdokumente mit Verknuepfungen',
|
||||
},
|
||||
declaration_of_conformity: {
|
||||
icon: '📜',
|
||||
description: 'EU-Konformitaetserklaerung',
|
||||
},
|
||||
instructions_for_use: {
|
||||
icon: '📖',
|
||||
description: 'Sicherheitshinweise fuer Betriebsanleitung',
|
||||
},
|
||||
monitoring_plan: {
|
||||
icon: '📡',
|
||||
description: 'Post-Market Surveillance Plan',
|
||||
},
|
||||
}
|
||||
|
||||
const STATUS_CONFIG: Record<string, { label: string; color: string; bgColor: string }> = {
|
||||
empty: { label: 'Leer', color: 'text-gray-500', bgColor: 'bg-gray-100' },
|
||||
draft: { label: 'Entwurf', color: 'text-yellow-700', bgColor: 'bg-yellow-100' },
|
||||
generated: { label: 'Generiert', color: 'text-blue-700', bgColor: 'bg-blue-100' },
|
||||
reviewed: { label: 'Geprueft', color: 'text-orange-700', bgColor: 'bg-orange-100' },
|
||||
approved: { label: 'Freigegeben', color: 'text-green-700', bgColor: 'bg-green-100' },
|
||||
}
|
||||
|
||||
const EXPORT_FORMATS: { value: string; label: string; extension: string }[] = [
|
||||
{ value: 'pdf', label: 'PDF', extension: '.pdf' },
|
||||
{ value: 'xlsx', label: 'Excel', extension: '.xlsx' },
|
||||
{ value: 'docx', label: 'Word', extension: '.docx' },
|
||||
{ value: 'md', label: 'Markdown', extension: '.md' },
|
||||
{ value: 'json', label: 'JSON', extension: '.json' },
|
||||
]
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const config = STATUS_CONFIG[status] || STATUS_CONFIG.empty
|
||||
return (
|
||||
@@ -197,7 +140,12 @@ export default function TechFilePage() {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/tech-file`)
|
||||
if (res.ok) {
|
||||
const json = await res.json()
|
||||
setSections(json.sections || json || [])
|
||||
const raw = json.sections || json || []
|
||||
// Map html_content → content for frontend compatibility
|
||||
setSections(raw.map((s: Record<string, unknown>) => ({
|
||||
...s,
|
||||
content: s.content || s.html_content || null,
|
||||
})))
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch tech file sections:', err)
|
||||
@@ -308,7 +256,10 @@ export default function TechFilePage() {
|
||||
Sie alle erforderlichen Abschnitte.
|
||||
</p>
|
||||
</div>
|
||||
{/* Export Dropdown */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Risk Report Export (PDF + Excel) — always available */}
|
||||
<ReportGenerator projectId={projectId} />
|
||||
{/* Tech-File Export Dropdown — requires all sections approved */}
|
||||
<div className="relative" ref={exportMenuRef}>
|
||||
<button
|
||||
onClick={() => setShowExportMenu((prev) => !prev)}
|
||||
@@ -347,6 +298,7 @@ export default function TechFilePage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
|
||||
+26
-7
@@ -14,6 +14,12 @@ export function SuggestEvidenceModal({
|
||||
const [selectedMitigation, setSelectedMitigation] = useState<string>('')
|
||||
const [suggested, setSuggested] = useState<SuggestedEvidence[]>([])
|
||||
const [loadingSuggestions, setLoadingSuggestions] = useState(false)
|
||||
const [search, setSearch] = useState('')
|
||||
|
||||
const filtered = search.trim()
|
||||
? mitigations.filter(m => (m.title || '').toLowerCase().includes(search.toLowerCase()))
|
||||
: mitigations
|
||||
const displayed = filtered.slice(0, 20) // Show max 20 at a time
|
||||
|
||||
async function handleSelectMitigation(mitigationId: string) {
|
||||
setSelectedMitigation(mitigationId)
|
||||
@@ -41,22 +47,35 @@ export function SuggestEvidenceModal({
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mb-3">
|
||||
Waehlen Sie eine Massnahme, um passende Nachweismethoden vorgeschlagen zu bekommen.
|
||||
<p className="text-sm text-gray-500 mb-2">
|
||||
Waehlen Sie eine Massnahme ({mitigations.length} gesamt). Suchen Sie nach Name:
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{mitigations.map(m => (
|
||||
<input
|
||||
type="text" value={search} onChange={e => setSearch(e.target.value)}
|
||||
placeholder="Massnahme suchen..."
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg mb-3 focus:ring-2 focus:ring-purple-400 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
/>
|
||||
<div className="max-h-[200px] overflow-auto space-y-1">
|
||||
{displayed.map(m => (
|
||||
<button
|
||||
key={m.id} onClick={() => handleSelectMitigation(m.id)}
|
||||
className={`px-3 py-1.5 text-xs rounded-lg border transition-colors ${
|
||||
className={`w-full text-left px-3 py-2 text-xs rounded-lg border transition-colors ${
|
||||
selectedMitigation === m.id
|
||||
? 'border-purple-400 bg-purple-50 text-purple-700 font-medium'
|
||||
: 'border-gray-200 bg-white text-gray-700 hover:border-purple-300'
|
||||
: 'border-gray-100 bg-white text-gray-700 hover:border-purple-300 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{m.title}
|
||||
{m.title || '(Ohne Titel)'}
|
||||
</button>
|
||||
))}
|
||||
{filtered.length > 20 && (
|
||||
<p className="text-xs text-gray-400 text-center py-1">
|
||||
{filtered.length - 20} weitere — Suchbegriff eingeben um zu filtern
|
||||
</p>
|
||||
)}
|
||||
{filtered.length === 0 && (
|
||||
<p className="text-xs text-gray-400 text-center py-2">Keine Massnahmen gefunden</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
|
||||
@@ -23,18 +23,25 @@ export default function VerificationPage() {
|
||||
|
||||
async function fetchData() {
|
||||
try {
|
||||
const [verRes, hazRes, mitRes] = await Promise.all([
|
||||
fetch(`/api/sdk/v1/iace/projects/${projectId}/verifications`),
|
||||
fetch(`/api/sdk/v1/iace/projects/${projectId}/hazards`),
|
||||
fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations`),
|
||||
])
|
||||
// Only load verifications initially — hazards/mitigations loaded on demand
|
||||
const verRes = await fetch(`/api/sdk/v1/iace/projects/${projectId}/verifications`)
|
||||
if (verRes.ok) { const j = await verRes.json(); setItems(j.verifications || j || []) }
|
||||
if (hazRes.ok) { const j = await hazRes.json(); setHazards((j.hazards || j || []).map((h: { id: string; name: string }) => ({ id: h.id, name: h.name }))) }
|
||||
if (mitRes.ok) { const j = await mitRes.json(); setMitigations((j.mitigations || j || []).map((m: { id: string; title: string }) => ({ id: m.id, title: m.title }))) }
|
||||
} catch (err) { console.error('Failed to fetch data:', err) }
|
||||
finally { setLoading(false) }
|
||||
}
|
||||
|
||||
async function loadMitigationsIfNeeded() {
|
||||
if (mitigations.length > 0) return
|
||||
try {
|
||||
const mitRes = await fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations`)
|
||||
if (mitRes.ok) {
|
||||
const j = await mitRes.json()
|
||||
const mits = (j.mitigations || j || []).map((m: Record<string, string>) => ({ id: m.id, title: m.title || m.name || '' }))
|
||||
setMitigations(mits)
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
async function handleSubmit(data: VerificationFormData) {
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/verifications`, {
|
||||
@@ -89,8 +96,8 @@ export default function VerificationPage() {
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">Nachweisfuehrung fuer alle Schutzmassnahmen und Sicherheitsanforderungen.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{mitigations.length > 0 && (
|
||||
<button onClick={() => 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">
|
||||
{true && (
|
||||
<button onClick={async () => { await loadMitigationsIfNeeded(); 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">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<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>
|
||||
@@ -147,7 +154,7 @@ export default function VerificationPage() {
|
||||
</p>
|
||||
<div className="mt-6 flex items-center justify-center gap-3">
|
||||
{mitigations.length > 0 && (
|
||||
<button onClick={() => setShowSuggest(true)} className="px-6 py-3 border border-green-300 text-green-700 rounded-lg hover:bg-green-50 transition-colors">
|
||||
<button onClick={async () => { await loadMitigationsIfNeeded(); setShowSuggest(true) }} className="px-6 py-3 border border-green-300 text-green-700 rounded-lg hover:bg-green-50 transition-colors">
|
||||
Nachweise vorschlagen
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -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,16 +3,23 @@
|
||||
import React from 'react'
|
||||
import Link from 'next/link'
|
||||
import { usePathname, useParams } from 'next/navigation'
|
||||
import IACEFlowFAB from './[projectId]/_components/IACEFlowFAB'
|
||||
|
||||
const IACE_NAV_ITEMS = [
|
||||
{ id: 'overview', label: 'Uebersicht', href: '', icon: 'grid' },
|
||||
{ id: 'order', label: 'Auftrag', href: '/order', icon: 'briefcase' },
|
||||
{ id: 'interview', label: 'Grenzen & Verwendung', href: '/interview', icon: 'chat' },
|
||||
{ id: 'norms', label: 'Normenrecherche', href: '/norms', icon: 'book' },
|
||||
{ id: 'components', label: 'Komponenten', href: '/components', icon: 'cube' },
|
||||
{ id: 'classification', label: 'Klassifikation', href: '/classification', icon: 'tag' },
|
||||
{ id: 'hazards', label: 'Hazard Log', href: '/hazards', icon: 'warning' },
|
||||
{ id: 'mitigations', label: 'Massnahmen', href: '/mitigations', icon: 'shield' },
|
||||
{ id: 'verification', label: 'Verifikation', href: '/verification', icon: 'check' },
|
||||
{ id: 'evidence', label: 'Nachweise', href: '/evidence', icon: 'document' },
|
||||
{ id: 'tech-file', label: 'CE-Akte', href: '/tech-file', icon: 'folder' },
|
||||
]
|
||||
|
||||
const IACE_EXTRA_ITEMS = [
|
||||
{ id: 'classification', label: 'Klassifikation', href: '/classification', icon: 'tag' },
|
||||
{ id: 'monitoring', label: 'Monitoring', href: '/monitoring', icon: 'activity' },
|
||||
]
|
||||
|
||||
@@ -73,6 +80,24 @@ function NavIcon({ icon, className }: { icon: string; className?: string }) {
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
)
|
||||
case 'briefcase':
|
||||
return (
|
||||
<svg className={cls} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m10 0H6a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V8a2 2 0 00-2-2z" />
|
||||
</svg>
|
||||
)
|
||||
case 'chat':
|
||||
return (
|
||||
<svg className={cls} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
</svg>
|
||||
)
|
||||
case 'book':
|
||||
return (
|
||||
<svg className={cls} 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>
|
||||
)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
@@ -82,6 +107,15 @@ export default function IACELayout({ children }: { children: React.ReactNode })
|
||||
const pathname = usePathname()
|
||||
const params = useParams()
|
||||
const projectId = params?.projectId as string | undefined
|
||||
const [projectName, setProjectName] = React.useState('')
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!projectId) return
|
||||
fetch(`/api/sdk/v1/iace/projects/${projectId}`)
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(d => { if (d?.machine_name) setProjectName(d.machine_name) })
|
||||
.catch(() => {})
|
||||
}, [projectId])
|
||||
|
||||
const basePath = projectId ? `/sdk/iace/${projectId}` : ''
|
||||
|
||||
@@ -109,9 +143,21 @@ export default function IACELayout({ children }: { children: React.ReactNode })
|
||||
</svg>
|
||||
Alle Projekte
|
||||
</Link>
|
||||
<h2 className="text-sm font-semibold text-gray-900 dark:text-white mt-2">
|
||||
CE-Compliance
|
||||
</h2>
|
||||
{projectName && (
|
||||
<p className="text-xs font-bold text-purple-700 dark:text-purple-400 mt-2 truncate" title={projectName}>
|
||||
{projectName}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-[10px] text-gray-400 mt-0.5">CE-Compliance</p>
|
||||
<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>
|
||||
<nav className="p-2 space-y-0.5">
|
||||
{IACE_NAV_ITEMS.map((item) => (
|
||||
@@ -129,6 +175,26 @@ export default function IACELayout({ children }: { children: React.ReactNode })
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
{/* Extra modules (not part of standard CE flow) */}
|
||||
<div className="px-4 pt-3 mt-1 border-t border-gray-200 dark:border-gray-700">
|
||||
<p className="text-xs text-gray-400 uppercase tracking-wider mb-1">Zusatzmodule</p>
|
||||
</div>
|
||||
<nav className="px-2 pb-2 space-y-0.5">
|
||||
{IACE_EXTRA_ITEMS.map((item) => (
|
||||
<Link
|
||||
key={item.id}
|
||||
href={`${basePath}${item.href}`}
|
||||
className={`flex items-center gap-2.5 px-3 py-2 rounded-lg text-xs font-medium transition-colors ${
|
||||
isActive(item.href)
|
||||
? 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
|
||||
: 'text-gray-400 hover:bg-gray-50 dark:text-gray-500 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<NavIcon icon={item.icon} className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
<span className="truncate">{item.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
)}
|
||||
|
||||
@@ -136,6 +202,9 @@ export default function IACELayout({ children }: { children: React.ReactNode })
|
||||
<main className="flex-1 overflow-auto bg-gray-50 dark:bg-gray-900">
|
||||
<div className="p-6">{children}</div>
|
||||
</main>
|
||||
|
||||
{/* CE Process Step Navigator FAB */}
|
||||
{projectId && <IACEFlowFAB />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
|
||||
// ---------- Pagination ----------
|
||||
interface PaginationProps {
|
||||
page: number
|
||||
totalPages: number
|
||||
onPageChange: (p: number) => void
|
||||
}
|
||||
|
||||
export function Pagination({ page, totalPages, onPageChange }: PaginationProps) {
|
||||
if (totalPages <= 1) return null
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
disabled={page <= 1}
|
||||
className="px-3 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 disabled:opacity-40 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
‹ Zurueck
|
||||
</button>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Seite {page} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
disabled={page >= totalPages}
|
||||
className="px-3 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 disabled:opacity-40 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
Weiter ›
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------- Search ----------
|
||||
interface SearchInputProps {
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
export function SearchInput({ value, onChange, placeholder }: SearchInputProps) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder || 'Suchen...'}
|
||||
className="w-full pl-10 pr-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------- Filter Dropdown ----------
|
||||
interface FilterDropdownProps {
|
||||
label: string
|
||||
value: string
|
||||
options: { value: string; label: string }[]
|
||||
onChange: (v: string) => void
|
||||
}
|
||||
|
||||
export function FilterDropdown({ label, value, options, onChange }: FilterDropdownProps) {
|
||||
return (
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
aria-label={label}
|
||||
className="px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
>
|
||||
{options.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------- Expandable Row ----------
|
||||
interface ExpandableRowProps {
|
||||
cells: React.ReactNode[]
|
||||
expandedContent: React.ReactNode
|
||||
colSpan: number
|
||||
}
|
||||
|
||||
export function ExpandableRow({ cells, expandedContent, colSpan }: ExpandableRowProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
return (
|
||||
<>
|
||||
<tr
|
||||
onClick={() => setOpen(!open)}
|
||||
className="cursor-pointer hover:bg-purple-50/50 dark:hover:bg-purple-900/10 transition-colors even:bg-gray-50/50 dark:even:bg-gray-800/30"
|
||||
>
|
||||
{cells.map((cell, i) => (
|
||||
<td key={i} className="px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 whitespace-nowrap">
|
||||
{cell}
|
||||
</td>
|
||||
))}
|
||||
<td className="px-3 py-2.5 text-gray-400">
|
||||
<svg className={`w-4 h-4 transition-transform ${open ? '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>
|
||||
</td>
|
||||
</tr>
|
||||
{open && (
|
||||
<tr className="bg-purple-50/30 dark:bg-purple-900/5">
|
||||
<td colSpan={colSpan + 1} className="px-6 py-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
{expandedContent}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------- External Link Icon ----------
|
||||
export function ExternalLinkIcon() {
|
||||
return (
|
||||
<svg className="w-3.5 h-3.5 inline-block ml-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
'use client'
|
||||
|
||||
import React, { useMemo, useState, useRef, useEffect } from 'react'
|
||||
import { SearchInput, FilterDropdown, Pagination, ExpandableRow } from './LibraryTable'
|
||||
|
||||
export interface ProtectiveMeasure {
|
||||
id: string
|
||||
reduction_type: string
|
||||
sub_type: string
|
||||
name: string
|
||||
description: string
|
||||
hazard_category: string
|
||||
examples: string[]
|
||||
norm_references: string[]
|
||||
}
|
||||
|
||||
const PER_PAGE = 50
|
||||
const TYPE_OPTIONS = [
|
||||
{ value: '', label: 'Alle Typen' },
|
||||
{ value: 'Design', label: 'Design' },
|
||||
{ value: 'Schutz', label: 'Schutz' },
|
||||
{ value: 'Information', label: 'Information' },
|
||||
]
|
||||
|
||||
function typeColor(t: string): string {
|
||||
switch (t) {
|
||||
case 'Design': return 'bg-blue-100 text-blue-800'
|
||||
case 'Schutz': return 'bg-green-100 text-green-800'
|
||||
case 'Information': return 'bg-yellow-100 text-yellow-800'
|
||||
default: return 'bg-gray-100 text-gray-700'
|
||||
}
|
||||
}
|
||||
|
||||
interface Props { measures: ProtectiveMeasure[] }
|
||||
|
||||
export default function MeasuresTab({ measures }: Props) {
|
||||
const [search, setSearch] = useState('')
|
||||
const [debounced, setDebounced] = useState('')
|
||||
const [typeFilter, setTypeFilter] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
timer.current = setTimeout(() => setDebounced(search), 300)
|
||||
return () => { if (timer.current) clearTimeout(timer.current) }
|
||||
}, [search])
|
||||
|
||||
useEffect(() => { setPage(1) }, [debounced, typeFilter])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = debounced.toLowerCase()
|
||||
return measures.filter((m) => {
|
||||
if (q && !m.name.toLowerCase().includes(q) && !m.description.toLowerCase().includes(q)) return false
|
||||
if (typeFilter && m.reduction_type !== typeFilter) return false
|
||||
return true
|
||||
})
|
||||
}, [measures, debounced, typeFilter])
|
||||
|
||||
const totalPages = Math.ceil(filtered.length / PER_PAGE)
|
||||
const pageItems = filtered.slice((page - 1) * PER_PAGE, page * PER_PAGE)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<SearchInput value={search} onChange={setSearch} placeholder="Name oder Beschreibung suchen..." />
|
||||
</div>
|
||||
<FilterDropdown label="Typ" value={typeFilter} options={TYPE_OPTIONS} onChange={setTypeFilter} />
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400 ml-auto">
|
||||
{measures.length} Massnahmen{filtered.length !== measures.length && ` (${filtered.length} gefiltert)`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<table className="w-full text-left">
|
||||
<thead className="bg-gray-100 dark:bg-gray-800">
|
||||
<tr>
|
||||
{['ID', 'Name', 'Typ', 'Subtyp', 'Kategorie', 'Normen'].map((h) => (
|
||||
<th key={h} className="px-4 py-2.5 text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider">{h}</th>
|
||||
))}
|
||||
<th className="w-8" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pageItems.map((m) => (
|
||||
<ExpandableRow
|
||||
key={m.id}
|
||||
colSpan={6}
|
||||
cells={[
|
||||
<span key="id" className="font-mono text-xs text-gray-500">{m.id.slice(0, 8)}</span>,
|
||||
<span key="name" className="max-w-xs truncate block">{m.name}</span>,
|
||||
<span key="type" className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${typeColor(m.reduction_type)}`}>{m.reduction_type}</span>,
|
||||
<span key="sub" className="text-xs">{m.sub_type || '-'}</span>,
|
||||
<span key="cat" className="text-xs">{m.hazard_category?.replace(/_/g, ' ') || '-'}</span>,
|
||||
<span key="norms" className="text-xs text-gray-500">{m.norm_references.length > 0 ? m.norm_references.slice(0, 2).join(', ') : '-'}{m.norm_references.length > 2 ? ` +${m.norm_references.length - 2}` : ''}</span>,
|
||||
]}
|
||||
expandedContent={
|
||||
<div className="space-y-2">
|
||||
{m.description && <div><span className="font-medium text-gray-700 dark:text-gray-300">Beschreibung:</span> {m.description}</div>}
|
||||
{m.examples.length > 0 && (
|
||||
<div>
|
||||
<span className="font-medium text-gray-700 dark:text-gray-300">Beispiele:</span>
|
||||
<ul className="list-disc ml-5 mt-1 space-y-0.5">
|
||||
{m.examples.map((ex, i) => <li key={i}>{ex}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{m.norm_references.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{m.norm_references.map((nr) => (
|
||||
<span key={nr} className="px-1.5 py-0.5 rounded text-xs bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 font-mono">{nr}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{pageItems.length === 0 && (
|
||||
<tr><td colSpan={7} className="px-4 py-8 text-center text-sm text-gray-400">Keine Massnahmen gefunden</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user