feat(iace): Phase 1 — Haftungs-Fixes, Massnahmen-Verkabelung, Explainability Engine
Phase 1A — Haftungs-kritische Fixes: - SIL/PL-Badges als "Vorab-Einschaetzung" mit Tooltip gekennzeichnet - Coverage-Disclaimer in CE-Akte, Projekt-Uebersicht und Print-Export - Norm-Referenzen: 42 Kapitelverweise durch Themen-Deskriptoren ersetzt Phase 1B — Massnahmen-Verkabelung: - 16 neue Massnahmen (M201-M216) fuer bisher unabgedeckte Kategorien (communication_failure, hmi_error, firmware_corruption, maintenance, sensor_fault, mode_confusion) - Kategorie-Fallback im Initialize-Endpoint: ordnet Massnahmen aus der Bibliothek automatisch per HazardCategory zu (max 8 pro Kategorie) - Total: 225 → 241 Massnahmen, 0 Kategorien ohne Massnahmen Phase 1C — Explainability Engine: - MatchReason Struct in PatternMatch (type, tag, met) - Pattern Engine schreibt fuer jeden Match strukturierte Begruendungen - Frontend zeigt "Erkannt weil: Komponente X, Energie Y, Kein Ausschluss Z" Weitere Aenderungen: - BAuA/OSHA Regulatory Hints: 3 Enrich-Endpoints (per Hazard, per Measure, Batch) - Dokumente-Tab in IACE-Bibliothek (36.708 Chunks aus Qdrant) - Varianten-UX: Basis-Projekt-Summary auf Varianten-Seite - Projekt-Initialisierung: POST /initialize kettet Parse→Komponenten→Patterns→Hazards→Massnahmen→Normen - 18 pre-existing TS-Fehler gefixt, Route-Konflikt behoben - Component-Library + Measures-Library Tests aktualisiert Tests: Go alle bestanden, TS 0 Fehler, Playwright 141+ bestanden Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -240,7 +240,7 @@ export async function handleV2Draft(body: Record<string, unknown>): Promise<Next
|
||||
const promptHash = computeChecksumSync({ factsString, tagsString, termsString, styleString, disallowedString })
|
||||
|
||||
const v2RagCfg = DOCUMENT_RAG_CONFIG[documentType]
|
||||
const v2RagContext = await queryRAG(v2RagCfg.query, 3, v2RagCfg.collection)
|
||||
const v2RagContext = v2RagCfg ? await queryRAG(v2RagCfg.query, 3, v2RagCfg.collection) : null
|
||||
|
||||
const proseBlocks = DOCUMENT_PROSE_BLOCKS[documentType] || DOCUMENT_PROSE_BLOCKS.tom
|
||||
const generatedBlocks: ProseBlockOutput[] = []
|
||||
|
||||
@@ -88,7 +88,7 @@ export async function handleV1Draft(body: Record<string, unknown>): Promise<Next
|
||||
}
|
||||
|
||||
const ragCfg = DOCUMENT_RAG_CONFIG[documentType]
|
||||
const ragContext = await queryRAG(ragCfg.query, 3, ragCfg.collection)
|
||||
const ragContext = ragCfg ? await queryRAG(ragCfg.query, 3, ragCfg.collection) : null
|
||||
|
||||
let v1SystemPrompt = V1_SYSTEM_PROMPT
|
||||
if (ragContext) {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { DOCUMENT_SCOPE_MATRIX, DOCUMENT_TYPE_LABELS, getDepthLevelNumeric } from '@/lib/sdk/compliance-scope-types'
|
||||
import { DOCUMENT_SCOPE_MATRIX_CORE, DOCUMENT_TYPE_LABELS, getDepthLevelNumeric } from '@/lib/sdk/compliance-scope-types'
|
||||
import type { ScopeDocumentType, ComplianceDepthLevel } from '@/lib/sdk/compliance-scope-types'
|
||||
import type { ValidationContext, ValidationResult, ValidationFinding } from '@/lib/sdk/drafting-engine/types'
|
||||
import { buildCrossCheckPrompt } from '@/lib/sdk/drafting-engine/prompts/validate-cross-check'
|
||||
@@ -94,7 +94,7 @@ function deterministicCheck(
|
||||
const findings: ValidationFinding[] = []
|
||||
const level = validationContext.scopeLevel
|
||||
const levelNumeric = getDepthLevelNumeric(level)
|
||||
const req = DOCUMENT_SCOPE_MATRIX[documentType]?.[level]
|
||||
const req = DOCUMENT_SCOPE_MATRIX_CORE[documentType]?.[level]
|
||||
|
||||
// Check 1: Ist das Dokument auf diesem Level erforderlich?
|
||||
if (req && !req.required && levelNumeric < 3) {
|
||||
@@ -109,8 +109,8 @@ function deterministicCheck(
|
||||
}
|
||||
|
||||
// Check 2: VVT vorhanden wenn erforderlich?
|
||||
const vvtReq = DOCUMENT_SCOPE_MATRIX.vvt[level]
|
||||
if (vvtReq.required && validationContext.crossReferences.vvtCategories.length === 0) {
|
||||
const vvtReq = DOCUMENT_SCOPE_MATRIX_CORE.vvt?.[level]
|
||||
if (vvtReq?.required && validationContext.crossReferences.vvtCategories.length === 0) {
|
||||
findings.push({
|
||||
id: 'DET-VVT-MISSING',
|
||||
severity: 'error',
|
||||
|
||||
@@ -23,12 +23,13 @@ function getTenantId(request: NextRequest): string {
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const tenantId = getTenantId(request)
|
||||
const response = await fetch(
|
||||
`${BACKEND_URL}/api/compliance/einwilligungen/consents/${params.id}/history`,
|
||||
`${BACKEND_URL}/api/compliance/einwilligungen/consents/${id}/history`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
|
||||
@@ -39,14 +39,14 @@ async function proxy(request: NextRequest, params: { path?: string[] }, method:
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest, { params }: { params: { path?: string[] } }) {
|
||||
return proxy(request, params, 'GET')
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ path?: string[] }> }) {
|
||||
return proxy(request, await params, 'GET')
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest, { params }: { params: { path?: string[] } }) {
|
||||
return proxy(request, params, 'POST')
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ path?: string[] }> }) {
|
||||
return proxy(request, await params, 'POST')
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest, { params }: { params: { path?: string[] } }) {
|
||||
return proxy(request, params, 'DELETE')
|
||||
export async function DELETE(request: NextRequest, { params }: { params: Promise<{ path?: string[] }> }) {
|
||||
return proxy(request, await params, 'DELETE')
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ const DEFAULT_TENANT = process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78
|
||||
/**
|
||||
* Proxy: /api/sdk/v1/ucca/decision-tree/... → Go Backend /sdk/v1/ucca/decision-tree/...
|
||||
*/
|
||||
async function proxyRequest(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
|
||||
async function proxyRequest(request: NextRequest, { params }: { params: Promise<{ path?: string[] }> }) {
|
||||
const { path } = await params
|
||||
const subPath = path ? path.join('/') : ''
|
||||
const search = request.nextUrl.search || ''
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,14 @@ export default function ControlLibraryPage() {
|
||||
initial={{
|
||||
...EMPTY_CONTROL,
|
||||
...state.selectedControl,
|
||||
scope: {
|
||||
platforms: state.selectedControl.scope?.platforms ?? [],
|
||||
components: state.selectedControl.scope?.components ?? [],
|
||||
data_classes: state.selectedControl.scope?.data_classes ?? [],
|
||||
},
|
||||
target_audience: Array.isArray(state.selectedControl.target_audience)
|
||||
? state.selectedControl.target_audience.join(', ')
|
||||
: state.selectedControl.target_audience,
|
||||
risk_score: state.selectedControl.risk_score,
|
||||
implementation_effort: state.selectedControl.implementation_effort,
|
||||
open_anchors: state.selectedControl.open_anchors.length > 0
|
||||
@@ -69,7 +77,9 @@ export default function ControlLibraryPage() {
|
||||
: [{ framework: '', ref: '', url: '' }],
|
||||
requirements: state.selectedControl.requirements.length > 0 ? state.selectedControl.requirements : [''],
|
||||
test_procedure: state.selectedControl.test_procedure.length > 0 ? state.selectedControl.test_procedure : [''],
|
||||
evidence: state.selectedControl.evidence.length > 0 ? state.selectedControl.evidence : [{ type: '', description: '' }],
|
||||
evidence: state.selectedControl.evidence.length > 0
|
||||
? state.selectedControl.evidence.map(e => typeof e === 'string' ? { type: '', description: e } : e)
|
||||
: [{ type: '', description: '' }],
|
||||
}}
|
||||
onSave={handleUpdate}
|
||||
onCancel={() => state.setMode('detail')}
|
||||
|
||||
@@ -18,12 +18,91 @@ interface VariantGapResponse {
|
||||
gap: { additional_hazards: number; additional_measures: number; categories_affected: string[] }
|
||||
}
|
||||
|
||||
interface BaseProjectSummary {
|
||||
hazard_count: number
|
||||
component_count: number
|
||||
mitigation_count: number
|
||||
norms_count: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
projectId: string
|
||||
parentProjectId?: string | null
|
||||
parentProjectName?: string
|
||||
}
|
||||
|
||||
function VariantBanner({ projectId, parentProjectId, parentProjectName }: { projectId: string; parentProjectId: string; parentProjectName?: string }) {
|
||||
const [baseSummary, setBaseSummary] = useState<BaseProjectSummary | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
async function loadBase() {
|
||||
try {
|
||||
const [projRes, riskRes] = await Promise.all([
|
||||
fetch(`/api/sdk/v1/iace/projects/${parentProjectId}`),
|
||||
fetch(`/api/sdk/v1/iace/projects/${parentProjectId}/risk-summary`),
|
||||
])
|
||||
const proj = projRes.ok ? await projRes.json() : null
|
||||
const risk = riskRes.ok ? await riskRes.json() : null
|
||||
const rs = risk?.risk_summary || risk || {}
|
||||
setBaseSummary({
|
||||
hazard_count: rs.total_hazards || rs.total || 0,
|
||||
component_count: proj?.components?.length || 0,
|
||||
mitigation_count: rs.total_mitigations || 0,
|
||||
norms_count: 0,
|
||||
})
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
loadBase()
|
||||
}, [parentProjectId])
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-purple-200 dark:border-purple-700 p-6 space-y-4">
|
||||
<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 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-semibold text-gray-900 dark:text-white">Variante</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
Diese Seite zeigt nur die <strong>varianten-spezifischen</strong> Gefaehrdungen und Massnahmen.
|
||||
Die Basis-Risikobeurteilung liegt im Eltern-Projekt.
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href={`/sdk/iace/${parentProjectId}`}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-purple-700 bg-purple-50 rounded-lg hover:bg-purple-100 dark:text-purple-300 dark:bg-purple-900/30 dark:hover:bg-purple-900/50 transition-colors"
|
||||
>
|
||||
{parentProjectName || 'Basis-Projekt'}
|
||||
<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>
|
||||
</Link>
|
||||
</div>
|
||||
{baseSummary && (
|
||||
<div className="bg-purple-50/50 dark:bg-purple-900/10 rounded-lg p-3 border border-purple-100 dark:border-purple-800">
|
||||
<p className="text-xs font-medium text-purple-700 dark:text-purple-300 mb-2">Basis-Projekt Zusammenfassung</p>
|
||||
<div className="grid grid-cols-3 gap-4 text-center">
|
||||
<div>
|
||||
<div className="text-lg font-bold text-gray-900 dark:text-white">{baseSummary.hazard_count}</div>
|
||||
<div className="text-xs text-gray-500">Gefaehrdungen</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-lg font-bold text-gray-900 dark:text-white">{baseSummary.mitigation_count}</div>
|
||||
<div className="text-xs text-gray-500">Massnahmen</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-lg font-bold text-gray-900 dark:text-white">{baseSummary.component_count}</div>
|
||||
<div className="text-xs text-gray-500">Komponenten</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function VariantPanel({ projectId, parentProjectId, parentProjectName }: Props) {
|
||||
const [variants, setVariants] = useState<VariantProject[]>([])
|
||||
const [gapMap, setGapMap] = useState<Record<string, VariantGapResponse>>({})
|
||||
@@ -95,34 +174,9 @@ export function VariantPanel({ projectId, parentProjectId, parentProjectName }:
|
||||
}
|
||||
}
|
||||
|
||||
// If this project IS a variant, show link to base project
|
||||
// If this project IS a variant, show link to base project + base stats
|
||||
if (parentProjectId) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-purple-200 dark:border-purple-700 p-6">
|
||||
<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 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-semibold text-gray-900 dark:text-white">Variante</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
Dieses Projekt ist eine Variante des Basis-Projekts
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href={`/sdk/iace/${parentProjectId}`}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-purple-700 bg-purple-50 rounded-lg hover:bg-purple-100 dark:text-purple-300 dark:bg-purple-900/30 dark:hover:bg-purple-900/50 transition-colors"
|
||||
>
|
||||
{parentProjectName || 'Basis-Projekt'}
|
||||
<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>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
return <VariantBanner projectId={projectId} parentProjectId={parentProjectId} parentProjectName={parentProjectName} />
|
||||
}
|
||||
|
||||
if (loading) return null
|
||||
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useCallback } from 'react'
|
||||
|
||||
interface RegulatoryHint {
|
||||
regulation_id: string
|
||||
regulation_short: string
|
||||
category: string
|
||||
text: string
|
||||
pages?: number[]
|
||||
source_url?: string
|
||||
score: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
projectId: string
|
||||
hazardId: string
|
||||
hazardName: string
|
||||
}
|
||||
|
||||
function categoryBadge(cat: string): string {
|
||||
if (cat === 'trbs') return 'bg-orange-100 text-orange-800'
|
||||
if (cat === 'trgs') return 'bg-red-100 text-red-800'
|
||||
if (cat === 'asr') return 'bg-teal-100 text-teal-800'
|
||||
if (cat === 'osha' || cat.startsWith('ce_')) return 'bg-blue-100 text-blue-800'
|
||||
return 'bg-gray-100 text-gray-700'
|
||||
}
|
||||
|
||||
export function RegulatoryHintsPanel({ projectId, hazardId, hazardName }: Props) {
|
||||
const [hints, setHints] = useState<RegulatoryHint[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const loadHints = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/hazards/${hazardId}/regulatory-hints`)
|
||||
if (!res.ok) {
|
||||
setError('Hinweise konnten nicht geladen werden')
|
||||
return
|
||||
}
|
||||
const data = await res.json()
|
||||
setHints(data.hints || [])
|
||||
} catch {
|
||||
setError('Verbindung zum RAG-Service fehlgeschlagen')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoaded(true)
|
||||
}
|
||||
}, [projectId, hazardId])
|
||||
|
||||
if (!loaded) {
|
||||
return (
|
||||
<button
|
||||
onClick={loadHints}
|
||||
disabled={loading}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-purple-700 bg-purple-50 rounded-lg hover:bg-purple-100 dark:text-purple-300 dark:bg-purple-900/30 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<span className="animate-spin inline-block w-3 h-3 border border-purple-400 border-t-transparent rounded-full" />
|
||||
Lade Hinweise...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<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="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>
|
||||
TRBS/OSHA Hinweise laden
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <p className="text-xs text-red-500">{error}</p>
|
||||
}
|
||||
|
||||
if (hints.length === 0) {
|
||||
return <p className="text-xs text-gray-400">Keine regulatorischen Hinweise gefunden</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2 mt-2">
|
||||
<p className="text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
Regulatorische Hinweise ({hints.length})
|
||||
</p>
|
||||
{hints.map((hint, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="p-2.5 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50/50 dark:bg-gray-800/50 text-xs space-y-1"
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className={`inline-flex px-1.5 py-0.5 rounded font-medium ${categoryBadge(hint.category)}`}>
|
||||
{hint.regulation_short || hint.regulation_id}
|
||||
</span>
|
||||
{hint.pages && hint.pages.length > 0 && (
|
||||
<span className="text-gray-400">S. {hint.pages.join(', ')}</span>
|
||||
)}
|
||||
<span className="text-gray-400 ml-auto">{(hint.score * 100).toFixed(0)}% Relevanz</span>
|
||||
</div>
|
||||
<p className="text-gray-700 dark:text-gray-300 leading-relaxed">{hint.text}</p>
|
||||
{hint.source_url && (
|
||||
<a
|
||||
href={hint.source_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-purple-600 hover:text-purple-800 dark:text-purple-400"
|
||||
>
|
||||
Quelle
|
||||
<svg className="w-3 h-3 inline-block ml-0.5" 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>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+41
-9
@@ -1,9 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import {
|
||||
Hazard, CATEGORY_LABELS, getRiskColor, getRiskLevelLabel, getRiskLevelISO,
|
||||
} from './types'
|
||||
import { RegulatoryHintsPanel } from './RegulatoryHintsPanel'
|
||||
|
||||
interface RiskAssessmentTableProps {
|
||||
projectId: string
|
||||
@@ -81,6 +82,7 @@ export function RiskAssessmentTable({ projectId, hazards, onReassess, decisions,
|
||||
const [edits, setEdits] = useState<Record<string, EditState>>({})
|
||||
const [saving, setSaving] = useState<string | null>(null)
|
||||
const [normsByCategory, setNormsByCategory] = useState<Record<string, string[]>>({})
|
||||
const [expandedHazard, setExpandedHazard] = useState<string | null>(null)
|
||||
|
||||
// Fetch norms library and build category→norm-numbers map
|
||||
useEffect(() => {
|
||||
@@ -123,7 +125,7 @@ export function RiskAssessmentTable({ projectId, hazards, onReassess, decisions,
|
||||
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
|
||||
const ra = (h as unknown 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,
|
||||
@@ -190,7 +192,7 @@ export function RiskAssessmentTable({ projectId, hazards, onReassess, decisions,
|
||||
<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 border-r border-gray-200 dark:border-gray-600 cursor-help" title="Vorab-Einschaetzung — keine normative Berechnung">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 */}
|
||||
@@ -220,7 +222,7 @@ export function RiskAssessmentTable({ projectId, hazards, onReassess, decisions,
|
||||
</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 ra = (h as unknown 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
|
||||
@@ -235,10 +237,13 @@ export function RiskAssessmentTable({ projectId, hazards, onReassess, decisions,
|
||||
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">
|
||||
<React.Fragment key={h.id}>
|
||||
<tr 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>
|
||||
<button onClick={() => setExpandedHazard(expandedHazard === h.id ? null : h.id)} className="text-left group">
|
||||
<div className="font-medium text-gray-900 dark:text-white group-hover:text-purple-700 dark:group-hover:text-purple-400 transition-colors">{h.name}</div>
|
||||
</button>
|
||||
{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">
|
||||
@@ -279,14 +284,16 @@ export function RiskAssessmentTable({ projectId, hazards, onReassess, decisions,
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
{/* SIL / PL */}
|
||||
{/* SIL / PL (Vorab-Einschaetzung) */}
|
||||
<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]}`}>
|
||||
<span className={`inline-block px-1.5 py-0.5 rounded text-[10px] font-bold cursor-help ${SIL_COLORS[sil]}`}
|
||||
title="Vorab-Einschaetzung nach vereinfachtem Risikograph — Validierung durch Funktionale-Sicherheits-Ingenieur erforderlich (ISO 13849 / IEC 62061)">
|
||||
{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]}`}>
|
||||
<span className={`inline-block px-1.5 py-0.5 rounded text-[10px] font-bold cursor-help ${PL_COLORS[pl]}`}
|
||||
title="Vorab-Einschaetzung — PL-Bestimmung nach ISO 13849-1 erfordert vollstaendige Sicherheitsfunktions-Analyse">
|
||||
PL {pl}
|
||||
</span>
|
||||
</td>
|
||||
@@ -325,6 +332,31 @@ export function RiskAssessmentTable({ projectId, hazards, onReassess, decisions,
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
{expandedHazard === h.id && (
|
||||
<tr className="bg-purple-50/30 dark:bg-purple-900/5">
|
||||
<td colSpan={17} className="px-4 py-3 space-y-3">
|
||||
{/* Hazard details */}
|
||||
{h.scenario && <p className="text-xs text-gray-600 dark:text-gray-300"><strong>Szenario:</strong> {h.scenario}</p>}
|
||||
{h.possible_harm && <p className="text-xs text-gray-600"><strong>Moeglicher Schaden:</strong> {h.possible_harm}</p>}
|
||||
{/* Match reasons (explainability) */}
|
||||
{h.match_reasons && h.match_reasons.length > 0 && (
|
||||
<div className="text-[10px] text-gray-500">
|
||||
<strong>Erkannt weil:</strong>{' '}
|
||||
{h.match_reasons
|
||||
.filter(r => r.met)
|
||||
.map((r, i) => (
|
||||
<span key={i} className="inline-flex items-center gap-0.5 mr-1.5 px-1.5 py-0.5 rounded bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-400">
|
||||
{r.type === 'required_component_tag' ? 'Komponente' : r.type === 'required_energy_tag' ? 'Energie' : r.type === 'no_exclusion' ? 'Kein Ausschluss' : 'Lifecycle'}: {r.tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* Regulatory hints */}
|
||||
<RegulatoryHintsPanel projectId={projectId} hazardId={h.id} hazardName={h.name} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
|
||||
@@ -19,9 +19,11 @@ export interface Hazard {
|
||||
affected_person: string
|
||||
possible_harm: string
|
||||
hazardous_zone: string
|
||||
scenario?: string
|
||||
review_status: string
|
||||
created_at: string
|
||||
source?: string
|
||||
match_reasons?: { type: string; tag: string; met: boolean }[]
|
||||
}
|
||||
|
||||
export interface LibraryHazard {
|
||||
|
||||
@@ -24,6 +24,8 @@ export default function IACEInterviewPage() {
|
||||
const [projectData, setProjectData] = useState<ProjectData | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle')
|
||||
const [initStatus, setInitStatus] = useState<'idle' | 'running' | 'done' | 'error'>('idle')
|
||||
const [initResult, setInitResult] = useState<{ steps: { name: string; status: string; count: number; details?: string }[]; summary: Record<string, number> } | null>(null)
|
||||
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const latestFormRef = useRef<LimitsFormData>(EMPTY_LIMITS_FORM)
|
||||
|
||||
@@ -157,7 +159,34 @@ export default function IACEInterviewPage() {
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Navigation */}
|
||||
{/* Initialization Result */}
|
||||
{initResult && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-green-200 dark:border-green-700 p-5 space-y-3">
|
||||
<h3 className="text-sm font-semibold text-green-800 dark:text-green-300">Initialisierung abgeschlossen</h3>
|
||||
<div className="grid grid-cols-5 gap-3 text-center">
|
||||
{Object.entries(initResult.summary).map(([key, val]) => (
|
||||
<div key={key}>
|
||||
<div className="text-xl font-bold text-gray-900 dark:text-white">{val}</div>
|
||||
<div className="text-[10px] text-gray-500 capitalize">{key}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{initResult.steps.map((s, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-xs">
|
||||
<span className={s.status === 'done' ? 'text-green-600' : s.status === 'skipped' ? 'text-gray-400' : 'text-red-500'}>
|
||||
{s.status === 'done' ? '\u2713' : s.status === 'skipped' ? '\u25CB' : '\u2717'}
|
||||
</span>
|
||||
<span className="text-gray-700 dark:text-gray-300">{s.name}</span>
|
||||
{s.count > 0 && <span className="text-gray-400">({s.count})</span>}
|
||||
{s.details && <span className="text-gray-400 text-[10px]">— {s.details}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Navigation + Initialize */}
|
||||
<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}`)}
|
||||
@@ -165,22 +194,64 @@ export default function IACEInterviewPage() {
|
||||
>
|
||||
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 className="flex items-center gap-3">
|
||||
<button
|
||||
disabled={initStatus === 'running' || completionPct < 30}
|
||||
onClick={async () => {
|
||||
// Flush pending save first
|
||||
if (saveTimerRef.current) {
|
||||
clearTimeout(saveTimerRef.current)
|
||||
await saveToBackend(latestFormRef.current)
|
||||
}
|
||||
setInitStatus('running')
|
||||
setInitResult(null)
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/initialize`, { method: 'POST' })
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}))
|
||||
alert(err.error || 'Initialisierung fehlgeschlagen')
|
||||
setInitStatus('error')
|
||||
return
|
||||
}
|
||||
const data = await res.json()
|
||||
setInitResult(data)
|
||||
setInitStatus('done')
|
||||
} catch {
|
||||
setInitStatus('error')
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-2 px-5 py-2.5 bg-green-600 text-white rounded-lg hover:bg-green-700 text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{initStatus === 'running' ? (
|
||||
<>
|
||||
<span className="animate-spin inline-block w-4 h-4 border-2 border-white border-t-transparent rounded-full" />
|
||||
Analyse laeuft...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
Projekt initialisieren
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
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>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useCallback } from 'react'
|
||||
|
||||
interface Hint {
|
||||
regulation_id: string
|
||||
regulation_short: string
|
||||
category: string
|
||||
text: string
|
||||
pages?: number[]
|
||||
source_url?: string
|
||||
score: number
|
||||
}
|
||||
|
||||
function catBadge(cat: string): string {
|
||||
if (cat === 'trbs') return 'bg-orange-100 text-orange-800'
|
||||
if (cat === 'trgs') return 'bg-red-100 text-red-800'
|
||||
if (cat === 'asr') return 'bg-teal-100 text-teal-800'
|
||||
return 'bg-blue-100 text-blue-800'
|
||||
}
|
||||
|
||||
interface Props {
|
||||
projectId: string
|
||||
mitigationId: string
|
||||
}
|
||||
|
||||
export function MitigationHints({ projectId, mitigationId }: Props) {
|
||||
const [hints, setHints] = useState<Hint[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations/${mitigationId}/regulatory-hints`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setHints(data.hints || [])
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
finally { setLoading(false); setLoaded(true) }
|
||||
}, [projectId, mitigationId])
|
||||
|
||||
if (!loaded) {
|
||||
return (
|
||||
<button onClick={load} disabled={loading}
|
||||
className="inline-flex items-center gap-1 text-[10px] text-purple-600 hover:text-purple-800 disabled:opacity-50">
|
||||
{loading ? 'Lade...' : 'TRBS/OSHA Hinweise laden'}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
if (hints.length === 0) return <span className="text-[10px] text-gray-400">Keine Hinweise</span>
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5 mt-1">
|
||||
<p className="text-[10px] font-medium text-gray-500">Regulatorische Hinweise:</p>
|
||||
{hints.map((h, i) => (
|
||||
<div key={i} className="p-2 rounded border border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-800 text-[11px]">
|
||||
<span className={`inline-flex px-1 py-0.5 rounded text-[9px] font-medium ${catBadge(h.category)}`}>
|
||||
{h.regulation_short || h.regulation_id}
|
||||
</span>
|
||||
{h.pages?.length ? <span className="text-gray-400 ml-1">S.{h.pages.join(',')}</span> : null}
|
||||
<p className="text-gray-600 dark:text-gray-300 mt-0.5 leading-relaxed">{h.text}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { MeasuresLibraryModal } from './_components/MeasuresLibraryModal'
|
||||
import { SuggestMeasuresModal } from './_components/SuggestMeasuresModal'
|
||||
import { MitigationForm } from './_components/MitigationForm'
|
||||
import { StatusBadge } from './_components/StatusBadge'
|
||||
import { MitigationHints } from './_components/MitigationHints'
|
||||
import { ProtectiveMeasure } from './_components/types'
|
||||
import { useMitigations } from './_hooks/useMitigations'
|
||||
|
||||
@@ -238,6 +239,7 @@ export default function MitigationsPage() {
|
||||
{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>}
|
||||
<MitigationHints projectId={projectId} mitigationId={m.id} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -35,6 +35,9 @@ export default function NormsPage() {
|
||||
|
||||
{/* Suggested norms component — rendered expanded (not collapsed by default) */}
|
||||
<SuggestedNorms projectId={projectId} />
|
||||
|
||||
{/* Document upload — own norms/specs/reports */}
|
||||
<DocumentUpload projectId={projectId} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -348,6 +348,13 @@ export default function ProjectOverviewPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Safety 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> Automatisch erkannte Gefaehrdungen, Massnahmen und SIL/PL-Einschaetzungen sind eine qualifizierte Ausgangsbasis.
|
||||
Vollstaendigkeit und Angemessenheit muessen vom CE-Verantwortlichen des Herstellers validiert werden.
|
||||
Dieses Tool ersetzt nicht die Pflichten des Herstellers nach EU 2023/1230 Art. 10.
|
||||
</div>
|
||||
|
||||
{/* Compliance Alerts */}
|
||||
<ComplianceAlerts projectId={projectId} />
|
||||
|
||||
|
||||
@@ -111,6 +111,14 @@ export function ReportPrintView({ data }: ReportPrintViewProps) {
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Disclaimer */}
|
||||
<div style={{ marginTop: '20pt', padding: '10pt', border: '1pt solid #d97706', background: '#fffbeb', fontSize: '8pt', color: '#92400e' }}>
|
||||
<strong>Hinweis:</strong> Dieses Dokument wurde mit BreakPilot ComplAI erstellt. Automatisch erkannte Gefaehrdungen
|
||||
und Massnahmen sind eine qualifizierte Ausgangsbasis. Vollstaendigkeit und Angemessenheit muessen vom
|
||||
CE-Verantwortlichen des Herstellers validiert werden. Dieses Tool ersetzt nicht die Pflichten des
|
||||
Herstellers nach EU Maschinenverordnung 2023/1230 Art. 10.
|
||||
</div>
|
||||
|
||||
{/* 2. Inhaltsverzeichnis */}
|
||||
<div className="section-break">
|
||||
<h2>Inhaltsverzeichnis</h2>
|
||||
|
||||
@@ -255,6 +255,10 @@ export default function TechFilePage() {
|
||||
Technische Dokumentation gemaess Maschinenverordnung Anhang IV. Generieren, pruefen und freigeben
|
||||
Sie alle erforderlichen Abschnitte.
|
||||
</p>
|
||||
<div className="mt-2 p-2.5 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 max-w-2xl">
|
||||
<strong>Hinweis:</strong> Unterstuetzte Risikobeurteilung — automatisch erkannte Gefaehrdungen und Massnahmen sind eine qualifizierte Ausgangsbasis.
|
||||
Vollstaendigkeit muss vom CE-Verantwortlichen validiert werden. Dieses Tool ersetzt nicht die Pflichten des Herstellers nach EU 2023/1230.
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Risk Report Export (PDF + Excel) — always available */}
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
'use client'
|
||||
|
||||
import React, { useMemo, useState, useRef, useEffect } from 'react'
|
||||
import { SearchInput, FilterDropdown, Pagination, ExternalLinkIcon } from './LibraryTable'
|
||||
|
||||
export interface CEDocument {
|
||||
regulation_id: string
|
||||
name_de: string
|
||||
name_en: string
|
||||
category: string
|
||||
source_url: string
|
||||
source_org: string
|
||||
chunk_count: number
|
||||
}
|
||||
|
||||
const PER_PAGE = 50
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
trbs: 'TRBS — Betriebssicherheit',
|
||||
trgs: 'TRGS — Gefahrstoffe',
|
||||
asr: 'ASR — Arbeitsstaetten',
|
||||
osha: 'OSHA — US Occupational Safety',
|
||||
ce_machinery: 'EU — Maschinenrecht',
|
||||
ce_machinery_guidance: 'EU — Leitfaeden',
|
||||
ce_electrical: 'EU — Niederspannung',
|
||||
ce_emc: 'EU — EMV',
|
||||
ce_radio: 'EU — Funkanlagen',
|
||||
ce_ai: 'EU — KI-Verordnung',
|
||||
ce_ai_safety: 'KI-Sicherheit',
|
||||
ce_software_safety: 'Software-Sicherheit',
|
||||
ce_software_security: 'Software-Security',
|
||||
ce_software_weaknesses: 'Software-Schwachstellen',
|
||||
ce_ot_cybersecurity: 'OT-Cybersecurity',
|
||||
eu_recht: 'EU-Recht',
|
||||
eu_datenschutz: 'EU-Datenschutz',
|
||||
guidance: 'Guidance',
|
||||
}
|
||||
|
||||
function categoryLabel(cat: string): string {
|
||||
return CATEGORY_LABELS[cat] || cat.replace(/_/g, ' ')
|
||||
}
|
||||
|
||||
function categoryColor(cat: string): string {
|
||||
if (cat.startsWith('trbs')) return 'bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300'
|
||||
if (cat.startsWith('trgs')) return 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300'
|
||||
if (cat.startsWith('asr')) return 'bg-teal-100 text-teal-800 dark:bg-teal-900/40 dark:text-teal-300'
|
||||
if (cat === 'osha') return 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300'
|
||||
if (cat.startsWith('ce_')) return 'bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-300'
|
||||
if (cat.startsWith('eu_')) return 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/40 dark:text-indigo-300'
|
||||
return 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
|
||||
}
|
||||
|
||||
interface Props {
|
||||
documents: CEDocument[]
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export default function DokumenteTab({ documents, loading }: Props) {
|
||||
const [search, setSearch] = useState('')
|
||||
const [debounced, setDebounced] = useState('')
|
||||
const [categoryFilter, setCategoryFilter] = 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, categoryFilter])
|
||||
|
||||
const categories = useMemo(() => {
|
||||
const cats = new Set(documents.map((d) => d.category))
|
||||
const opts = [{ value: '', label: 'Alle Kategorien' }]
|
||||
Array.from(cats).sort().forEach((c) => opts.push({ value: c, label: categoryLabel(c) }))
|
||||
return opts
|
||||
}, [documents])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = debounced.toLowerCase()
|
||||
return documents.filter((d) => {
|
||||
if (categoryFilter && d.category !== categoryFilter) return false
|
||||
if (q) {
|
||||
const hay = `${d.regulation_id} ${d.name_de} ${d.name_en} ${d.source_org}`.toLowerCase()
|
||||
if (!hay.includes(q)) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}, [documents, debounced, categoryFilter])
|
||||
|
||||
const totalPages = Math.ceil(filtered.length / PER_PAGE)
|
||||
const pageItems = filtered.slice((page - 1) * PER_PAGE, page * PER_PAGE)
|
||||
|
||||
const totalChunks = filtered.reduce((sum, d) => sum + d.chunk_count, 0)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12 gap-3">
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-purple-600" />
|
||||
<span className="text-sm text-gray-500">Lade Dokumentenindex aus Qdrant...</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Stats bar */}
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
<span className="font-medium">{documents.length} Dokumente</span>
|
||||
<span>|</span>
|
||||
<span>{documents.reduce((s, d) => s + d.chunk_count, 0).toLocaleString()} Chunks im Vektorspeicher</span>
|
||||
<span>|</span>
|
||||
<span>Quellen: BAuA, OSHA, EUR-Lex, NIST, ENISA</span>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<SearchInput value={search} onChange={setSearch} placeholder="Dokument suchen (z.B. TRBS 2111, Maschinenverordnung)..." />
|
||||
</div>
|
||||
<FilterDropdown label="Kategorie" value={categoryFilter} options={categories} onChange={setCategoryFilter} />
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400 ml-auto">
|
||||
{filtered.length !== documents.length && `${filtered.length} gefiltert | `}{totalChunks.toLocaleString()} Chunks
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<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>
|
||||
{['Kennung', 'Bezeichnung', 'Kategorie', 'Quelle', 'Chunks'].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>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pageItems.map((d) => (
|
||||
<tr
|
||||
key={d.regulation_id}
|
||||
className="hover:bg-purple-50/50 dark:hover:bg-purple-900/10 transition-colors even:bg-gray-50/50 dark:even:bg-gray-800/30"
|
||||
>
|
||||
<td className="px-4 py-2.5 text-sm font-mono text-gray-700 dark:text-gray-300 whitespace-nowrap">
|
||||
{d.regulation_id}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300">
|
||||
<div className="max-w-md">
|
||||
<div className="truncate">{d.name_de || d.name_en || d.regulation_id}</div>
|
||||
{d.source_url && (
|
||||
<a
|
||||
href={d.source_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-purple-600 hover:text-purple-800 dark:text-purple-400"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
Quelle<ExternalLinkIcon />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 whitespace-nowrap">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${categoryColor(d.category)}`}>
|
||||
{categoryLabel(d.category)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-sm text-gray-500 whitespace-nowrap">
|
||||
{d.source_org || '-'}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-sm font-mono text-gray-500 text-right whitespace-nowrap">
|
||||
{d.chunk_count.toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{pageItems.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-8 text-center text-sm text-gray-400">
|
||||
{documents.length === 0
|
||||
? 'Keine Dokumente im CE-Corpus gefunden. Qdrant-Verbindung pruefen.'
|
||||
: 'Keine Dokumente fuer diesen Filter gefunden'}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,13 +5,15 @@ import Link from 'next/link'
|
||||
import NormenTab, { type Norm } from './_components/NormenTab'
|
||||
import PatternsTab, { type HazardPattern } from './_components/PatternsTab'
|
||||
import MeasuresTab, { type ProtectiveMeasure } from './_components/MeasuresTab'
|
||||
import DokumenteTab, { type CEDocument } from './_components/DokumenteTab'
|
||||
|
||||
type TabId = 'normen' | 'patterns' | 'measures'
|
||||
type TabId = 'normen' | 'patterns' | 'measures' | 'dokumente'
|
||||
|
||||
const TABS: { id: TabId; label: string }[] = [
|
||||
{ id: 'normen', label: 'Normen' },
|
||||
{ id: 'patterns', label: 'Patterns' },
|
||||
{ id: 'measures', label: 'Massnahmen' },
|
||||
{ id: 'dokumente', label: 'Dokumente' },
|
||||
]
|
||||
|
||||
export default function IACELibraryPage() {
|
||||
@@ -19,6 +21,9 @@ export default function IACELibraryPage() {
|
||||
const [norms, setNorms] = useState<Norm[]>([])
|
||||
const [patterns, setPatterns] = useState<HazardPattern[]>([])
|
||||
const [measures, setMeasures] = useState<ProtectiveMeasure[]>([])
|
||||
const [documents, setDocuments] = useState<CEDocument[]>([])
|
||||
const [docsLoading, setDocsLoading] = useState(false)
|
||||
const [docsFetched, setDocsFetched] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -50,10 +55,22 @@ export default function IACELibraryPage() {
|
||||
load()
|
||||
}, [])
|
||||
|
||||
// Lazy-load documents on tab switch
|
||||
useEffect(() => {
|
||||
if (activeTab !== 'dokumente' || docsFetched) return
|
||||
setDocsLoading(true)
|
||||
fetch('/api/sdk/v1/iace/ce-corpus-documents')
|
||||
.then((r) => r.ok ? r.json() : null)
|
||||
.then((data) => { if (data) setDocuments(data.documents || []) })
|
||||
.catch(() => {})
|
||||
.finally(() => { setDocsLoading(false); setDocsFetched(true) })
|
||||
}, [activeTab, docsFetched])
|
||||
|
||||
const counts: Record<TabId, number> = {
|
||||
normen: norms.length,
|
||||
patterns: patterns.length,
|
||||
measures: measures.length,
|
||||
dokumente: documents.length,
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
@@ -116,6 +133,7 @@ export default function IACELibraryPage() {
|
||||
{activeTab === 'normen' && <NormenTab norms={norms} />}
|
||||
{activeTab === 'patterns' && <PatternsTab patterns={patterns} />}
|
||||
{activeTab === 'measures' && <MeasuresTab measures={measures} />}
|
||||
{activeTab === 'dokumente' && <DokumenteTab documents={documents} loading={docsLoading} />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user