Compare commits
5 Commits
f51671737a
...
a4b75dc6b1
| Author | SHA1 | Date | |
|---|---|---|---|
| a4b75dc6b1 | |||
| a1b9273649 | |||
| ac624f2e9b | |||
| a93ba9ee40 | |||
| 5244500af6 |
@@ -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>
|
||||
)
|
||||
}
|
||||
+29
-6
@@ -9,6 +9,10 @@ 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. */
|
||||
@@ -72,7 +76,7 @@ function InlineSelect({ value, onChange, label }: {
|
||||
// Main component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function RiskAssessmentTable({ projectId, hazards, onReassess }: RiskAssessmentTableProps) {
|
||||
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)
|
||||
@@ -249,12 +253,31 @@ export function RiskAssessmentTable({ projectId, hazards, onReassess }: RiskAsse
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-2 py-2 text-center">
|
||||
{afterRpz <= 20 ? (
|
||||
<span className="inline-block w-4 h-4 rounded-full bg-green-500 text-white text-[10px] leading-4 text-center" title="Akzeptabel">✓</span>
|
||||
) : afterRpz <= 60 ? (
|
||||
<span className="inline-block w-4 h-4 rounded-full bg-yellow-400 text-yellow-900 text-[10px] leading-4 text-center" title="Bedingt">≈</span>
|
||||
{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>
|
||||
) : (
|
||||
<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>
|
||||
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>
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } 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'
|
||||
@@ -16,6 +19,30 @@ export default function HazardsPage() {
|
||||
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 (
|
||||
@@ -64,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">
|
||||
@@ -124,9 +158,20 @@ 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 ? (
|
||||
view === 'risk' ? (
|
||||
<RiskAssessmentTable projectId={projectId} hazards={h.hazards} onReassess={h.refetch} />
|
||||
<>
|
||||
<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} />
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,85 +1,142 @@
|
||||
// IACE Interview Types — structured questions based on CE risk assessment document structure
|
||||
// IACE Limits & Intended Use Form Types — CE Risk Assessment Step 3
|
||||
// Based on ISO 12100 Sections 5.3 (Intended Use) and 5.4 (Limits)
|
||||
|
||||
export interface InterviewQuestion {
|
||||
id: string
|
||||
section: number
|
||||
sectionTitle: string
|
||||
question: string
|
||||
type: 'text' | 'textarea' | 'select' | 'multiselect' | 'number'
|
||||
options?: string[]
|
||||
placeholder?: string
|
||||
helpText?: string
|
||||
required?: boolean
|
||||
/** 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 interface InterviewAnswer {
|
||||
questionId: string
|
||||
value: string | string[] | number
|
||||
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 INTERVIEW_QUESTIONS: InterviewQuestion[] = [
|
||||
// Section 1: Maschinenbeschreibung
|
||||
{ id: 'machine_name', section: 1, sectionTitle: 'Maschinenbeschreibung', question: 'Wie heisst die Maschine / Anlage?', type: 'text', placeholder: 'z.B. Kniehebelpresse HP-500', required: true },
|
||||
{ id: 'machine_type', section: 1, sectionTitle: 'Maschinenbeschreibung', question: 'Welcher Maschinentyp ist es?', type: 'select', options: ['Presse', 'Roboter', 'CNC-Maschine', 'Foerderanlage', 'Verpackungsmaschine', 'Schweissanlage', 'Montageanlage', 'Sondermaschine'], required: true },
|
||||
{ id: 'manufacturer', section: 1, sectionTitle: 'Maschinenbeschreibung', question: 'Wer ist der Hersteller?', type: 'text', placeholder: 'z.B. Mueller Maschinenbau GmbH' },
|
||||
{ id: 'description', section: 1, sectionTitle: 'Maschinenbeschreibung', question: 'Beschreiben Sie die Anlage und ihre Funktion:', type: 'textarea', placeholder: 'Die Anlage ist eine vollautomatische...', helpText: 'Beschreiben Sie den Zweck, die Arbeitsweise und den Aufbau der Maschine.', required: true },
|
||||
{ id: 'components', section: 1, sectionTitle: 'Maschinenbeschreibung', question: 'Aus welchen Baugruppen besteht die Anlage?', type: 'multiselect', options: ['Zufuehrung', 'Presse/Umformung', 'Transferanlage', 'Foerderband', 'Roboter', 'Absaugung', 'Schmieranlage', 'Schutzumhausung', 'Aufzug/Hubwerk', 'Schaltschrank/Steuerung', 'Kuehlung', 'Heizung', 'Hydraulik', 'Pneumatik'] },
|
||||
|
||||
// Section 2: Lebensphasen
|
||||
{ id: 'lifecycle_operation', section: 2, sectionTitle: 'Lebensphasen', question: 'Wie laeuft der Normalbetrieb ab?', type: 'textarea', placeholder: 'Die Bearbeitung erfolgt vollautomatisch...', helpText: 'Beschreiben Sie den typischen Produktionszyklus.' },
|
||||
{ id: 'lifecycle_setup', section: 2, sectionTitle: 'Lebensphasen', question: 'Welche Arbeiten fallen beim Einrichten/Umruesten an?', type: 'textarea', placeholder: 'Werkzeugwechsel, Parameteranpassung...' },
|
||||
{ id: 'lifecycle_maintenance', section: 2, sectionTitle: 'Lebensphasen', question: 'Welche Wartungs- und Reinigungsarbeiten sind noetig?', type: 'textarea', placeholder: 'Woechentliche Schmierung, Filter reinigen...' },
|
||||
|
||||
// Section 3: Bestimmungsgemäße Verwendung
|
||||
{ id: 'intended_use', section: 3, sectionTitle: 'Bestimmungsgemäße Verwendung', question: 'Wozu dient die Maschine (bestimmungsgemäße Verwendung)?', type: 'textarea', placeholder: 'Die Anlage dient der automatischen...', required: true },
|
||||
|
||||
// Section 4: Vorhersehbare Fehlanwendung
|
||||
{ id: 'misuse', section: 4, sectionTitle: 'Vorhersehbare Fehlanwendung', question: 'Welche vorhersehbaren Fehlanwendungen sind moeglich?', type: 'multiselect', options: ['Ueberschreiten von Belastungsgrenzen', 'Verwendung ungeeigneter Materialien', 'Betrieb in explosionsgefaehrdeter Atmosphaere', 'Betrieb bei Leckagen', 'Betrieb ohne PSA', 'Umgehung von Sicherheitseinrichtungen', 'Bedienung ohne Einweisung', 'Manipulation der Steuerung'], helpText: 'Waehlen Sie alle zutreffenden oder ergaenzen Sie.' },
|
||||
|
||||
// Section 5: Qualifikation
|
||||
{ id: 'operator_qualification', section: 5, sectionTitle: 'Qualifikation der Benutzer', question: 'Welche Qualifikation hat das Bedienpersonal?', type: 'select', options: ['Eingewiesenes Personal ohne Fachkenntnisse', 'Angelernte Mitarbeiter', 'Facharbeiter mit Berufsausbildung', 'Ingenieure/Techniker', 'Elektrofachkraefte'] },
|
||||
{ id: 'maintenance_qualification', section: 5, sectionTitle: 'Qualifikation der Benutzer', question: 'Wer fuehrt Wartung/Instandhaltung durch?', type: 'select', options: ['Eigenes Fachpersonal', 'Hersteller-Service', 'Fremdfirma', 'Nicht separat betrachtet (CE-Erklaerung Lieferant)'] },
|
||||
|
||||
// Section 6: Grenzen
|
||||
{ id: 'spatial_limits', section: 6, sectionTitle: 'Raeumliche und zeitliche Grenzen', question: 'Welche Gefahrenbereiche gibt es?', type: 'textarea', placeholder: 'Werkzeugeinbauraum, Zufuehrbereich, Auslaufbereich...', helpText: 'Listen Sie alle Bereiche auf, in denen Personen gefaehrdet sein koennten.' },
|
||||
{ id: 'safety_measures_org', section: 6, sectionTitle: 'Raeumliche und zeitliche Grenzen', question: 'Welche organisatorischen Schutzmassnahmen gelten?', type: 'multiselect', options: ['Sicherheitsschuhe Pflicht', 'Gehoerschutz Pflicht', 'Handschuhe Pflicht', 'Schutzbrille Pflicht', 'Zutrittsbeschraenkung', 'Unterweisung vor Zugang'] },
|
||||
|
||||
// Section 7: Technische Daten
|
||||
{ id: 'force_pressure', section: 7, sectionTitle: 'Technische Daten', question: 'Welche Kraefte/Druecke wirken? (kN, bar, Tonnen)', type: 'text', placeholder: 'z.B. 20000 kN, 250 bar' },
|
||||
{ id: 'voltage', section: 7, sectionTitle: 'Technische Daten', question: 'Welche Spannungen sind vorhanden? (V)', type: 'text', placeholder: 'z.B. 400V Hauptstrom, 24V Steuerung' },
|
||||
{ id: 'temperature', section: 7, sectionTitle: 'Technische Daten', question: 'Treten erhoehte Temperaturen auf? (°C)', type: 'text', placeholder: 'z.B. 130°C Werkstuecktemperatur' },
|
||||
{ id: 'speed_rpm', section: 7, sectionTitle: 'Technische Daten', question: 'Welche Geschwindigkeiten/Drehzahlen gibt es? (/min, m/s)', type: 'text', placeholder: 'z.B. 736 /min Schwungrad, 36 Huebe/min' },
|
||||
{ id: 'energy', section: 7, sectionTitle: 'Technische Daten', question: 'Welches Arbeitsvermoegen hat die Maschine? (kJ, kW)', type: 'text', placeholder: 'z.B. 400 kJ, 3 kW Motor' },
|
||||
|
||||
// Section 8: Umgebung
|
||||
{ id: 'environment', section: 8, sectionTitle: 'Umgebungsbedingungen', question: 'Unter welchen Umgebungsbedingungen wird die Maschine betrieben?', type: 'textarea', placeholder: '+5 bis +40°C, max. 95% Luftfeuchte, bis 1000m ueNN', helpText: 'Temperatur, Luftfeuchte, Hoehenlage, besondere Bedingungen.' },
|
||||
export const AREA_OF_USE_OPTIONS = [
|
||||
'Industriell',
|
||||
'Gewerblich',
|
||||
'Privat',
|
||||
'Oeffentlich',
|
||||
]
|
||||
|
||||
export function answersToNarrativeText(answers: InterviewAnswer[]): string {
|
||||
const parts: string[] = []
|
||||
const getVal = (id: string) => {
|
||||
const a = answers.find(a => a.questionId === id)
|
||||
if (!a) return ''
|
||||
return Array.isArray(a.value) ? (a.value as string[]).join(', ') : String(a.value)
|
||||
}
|
||||
export const OPERATING_MODE_OPTIONS = [
|
||||
'Automatikbetrieb',
|
||||
'Einrichtbetrieb',
|
||||
'Handbetrieb',
|
||||
'Sonderbetrieb',
|
||||
'Reinigung',
|
||||
'Wartung',
|
||||
]
|
||||
|
||||
parts.push(`Maschinenname: ${getVal('machine_name')}. Maschinentyp: ${getVal('machine_type')}. Hersteller: ${getVal('manufacturer')}.`)
|
||||
if (getVal('description')) parts.push(getVal('description'))
|
||||
if (getVal('components')) parts.push(`Baugruppen: ${getVal('components')}.`)
|
||||
if (getVal('lifecycle_operation')) parts.push(`Betrieb: ${getVal('lifecycle_operation')}`)
|
||||
if (getVal('lifecycle_setup')) parts.push(`Einrichten: ${getVal('lifecycle_setup')}`)
|
||||
if (getVal('lifecycle_maintenance')) parts.push(`Wartung: ${getVal('lifecycle_maintenance')}`)
|
||||
if (getVal('intended_use')) parts.push(`Bestimmungsgemäße Verwendung: ${getVal('intended_use')}`)
|
||||
if (getVal('misuse')) parts.push(`Vorhersehbare Fehlanwendung: ${getVal('misuse')}`)
|
||||
if (getVal('operator_qualification')) parts.push(`Bedienpersonal: ${getVal('operator_qualification')}`)
|
||||
if (getVal('spatial_limits')) parts.push(`Gefahrenbereiche: ${getVal('spatial_limits')}`)
|
||||
if (getVal('safety_measures_org')) parts.push(`Organisatorische Massnahmen: ${getVal('safety_measures_org')}`)
|
||||
if (getVal('force_pressure')) parts.push(getVal('force_pressure'))
|
||||
if (getVal('voltage')) parts.push(getVal('voltage'))
|
||||
if (getVal('temperature')) parts.push(getVal('temperature'))
|
||||
if (getVal('speed_rpm')) parts.push(getVal('speed_rpm'))
|
||||
if (getVal('energy')) parts.push(getVal('energy'))
|
||||
if (getVal('environment')) parts.push(`Umgebung: ${getVal('environment')}`)
|
||||
export const PERSON_GROUP_OPTIONS = [
|
||||
'Bedienpersonal',
|
||||
'Einrichter',
|
||||
'Wartungspersonal',
|
||||
'Reinigungspersonal',
|
||||
'Auszubildende',
|
||||
'Besucher',
|
||||
'Fremdfirmenpersonal',
|
||||
]
|
||||
|
||||
return parts.join('\n')
|
||||
/** 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,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',
|
||||
}
|
||||
@@ -4,7 +4,9 @@ import { test, expect, Page } from '@playwright/test'
|
||||
* IACE (CE-Compliance) Module — Comprehensive E2E Tests
|
||||
*
|
||||
* Tests all 4 seeded projects across every tab:
|
||||
* Overview, Components, Hazards, Mitigations, Verification, Evidence, Tech-File, Monitoring.
|
||||
* Overview, Components, Hazards, Mitigations, Verification, Evidence, Tech-File, Monitoring,
|
||||
* Order, Interview (Grenzen & Verwendung), Compliance Alerts, Risk Assessment Table,
|
||||
* CE-Akte Export, Production Lines, Normenrecherche.
|
||||
*
|
||||
* Run with:
|
||||
* npx playwright test e2e/specs/iace-module.spec.ts --config e2e/playwright-live.config.ts --reporter=list
|
||||
@@ -43,6 +45,12 @@ const PROJECTS = [
|
||||
},
|
||||
] as const
|
||||
|
||||
/** The Cobot project ID — used for compliance alerts checks. */
|
||||
const COBOT_PROJECT_ID = 'a4c4031e-75a5-461e-a575-159f1eabd6b3'
|
||||
|
||||
/** Seeded production line ID. */
|
||||
const PRODUCTION_LINE_ID = 'c63b774e-22d4-4045-bb8d-646df626c42b'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -111,7 +119,7 @@ test.describe('IACE Start Page', () => {
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2–9. Per-project tests
|
||||
// 2–9. Per-project tests (existing tabs + new features)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
for (const project of PROJECTS) {
|
||||
@@ -175,6 +183,55 @@ for (const project of PROJECTS) {
|
||||
await assertNoAppError(page)
|
||||
})
|
||||
|
||||
// ------ Overview: Compliance Alerts ------
|
||||
test('overview — compliance alerts section visible', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${project.id}`)
|
||||
// ComplianceAlerts renders only when triggers > 0.
|
||||
// Wait for content to settle, then check for either the alerts header or
|
||||
// the absence of an error (some projects may have 0 triggers).
|
||||
await page.waitForTimeout(3000)
|
||||
await assertNoAppError(page)
|
||||
const body = await page.innerText('body')
|
||||
// At least one of these should be present on the overview page:
|
||||
// The alerts section OR the norms section OR the quick actions section
|
||||
const hasAlerts = body.includes('Compliance-Hinweise erkannt')
|
||||
const hasNorms = body.includes('Normenrecherche')
|
||||
const hasQuick = body.includes('Schnellzugriff')
|
||||
expect(hasAlerts || hasNorms || hasQuick).toBeTruthy()
|
||||
})
|
||||
|
||||
test('overview — regulation badges visible when alerts present', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${project.id}`)
|
||||
await page.waitForTimeout(3000)
|
||||
const body = await page.innerText('body')
|
||||
if (body.includes('Compliance-Hinweise erkannt')) {
|
||||
// Regulation badges should be rendered (DSGVO, AI Act, CRA, NIS2, Data Act)
|
||||
const hasBadge = body.includes('DSGVO') || body.includes('AI Act') || body.includes('CRA')
|
||||
expect(hasBadge).toBeTruthy()
|
||||
}
|
||||
})
|
||||
|
||||
// ------ Overview: Normenrecherche ------
|
||||
test('overview — normenrecherche section visible', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${project.id}`)
|
||||
await page.waitForTimeout(3000)
|
||||
const body = await page.innerText('body')
|
||||
// SuggestedNorms renders with the total count — verify it shows "relevante Normen"
|
||||
if (body.includes('Normenrecherche')) {
|
||||
expect(body).toContain('relevante Normen')
|
||||
}
|
||||
})
|
||||
|
||||
test('overview — norm add field visible when norms section open', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${project.id}`)
|
||||
await page.waitForTimeout(3000)
|
||||
// The SuggestedNorms section has a custom norm input with placeholder "z.B. ISO 13857:2019"
|
||||
const addInput = page.locator('input[placeholder*="ISO 13857"]')
|
||||
if (await addInput.count() > 0) {
|
||||
await expect(addInput.first()).toBeVisible({ timeout: 10000 })
|
||||
}
|
||||
})
|
||||
|
||||
// ------ Components ------
|
||||
test('components tab loads', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${project.id}/components`)
|
||||
@@ -196,6 +253,106 @@ for (const project of PROJECTS) {
|
||||
).toBeVisible({ timeout: 10000 })
|
||||
})
|
||||
|
||||
// ------ Order ------
|
||||
test('order tab loads', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${project.id}/order`)
|
||||
await assertNoAppError(page)
|
||||
await expect(page.locator('h1')).toContainText('Auftrag', { timeout: 15000 })
|
||||
})
|
||||
|
||||
test('order — form fields visible', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${project.id}/order`)
|
||||
// The Auftraggeber section with Firmenname and Ansprechpartner
|
||||
await expect(page.locator('text=Auftraggeber')).toBeVisible({ timeout: 10000 })
|
||||
await expect(page.locator('label:has-text("Firmenname")')).toBeVisible({ timeout: 10000 })
|
||||
await expect(page.locator('label:has-text("Ansprechpartner")')).toBeVisible({ timeout: 10000 })
|
||||
await expect(page.locator('label:has-text("E-Mail")')).toBeVisible({ timeout: 10000 })
|
||||
await expect(page.locator('label:has-text("Telefon")')).toBeVisible({ timeout: 10000 })
|
||||
})
|
||||
|
||||
test('order — status dropdown works', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${project.id}/order`)
|
||||
// The Angebot section has a status select with options
|
||||
const statusSelect = page.locator('select')
|
||||
await expect(statusSelect.first()).toBeVisible({ timeout: 10000 })
|
||||
// Verify dropdown has the expected options
|
||||
const options = statusSelect.first().locator('option')
|
||||
const count = await options.count()
|
||||
expect(count).toBeGreaterThanOrEqual(3) // offen, angenommen, abgelehnt, storniert
|
||||
})
|
||||
|
||||
test('order — Auftrag and Angebot sections visible', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${project.id}/order`)
|
||||
await expect(page.locator('text=Auftrag').first()).toBeVisible({ timeout: 10000 })
|
||||
await expect(page.locator('text=Angebot').first()).toBeVisible({ timeout: 10000 })
|
||||
await expect(page.locator('text=Notizen')).toBeVisible({ timeout: 10000 })
|
||||
})
|
||||
|
||||
test('order — scope checkboxes visible', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${project.id}/order`)
|
||||
// SCOPE_OPTIONS: Risikobeurteilung, Normenrecherche, Betriebsanleitung, CE-Kennzeichnung, Schulung
|
||||
await expect(page.locator('text=Risikobeurteilung').first()).toBeVisible({ timeout: 10000 })
|
||||
await expect(page.locator('text=CE-Kennzeichnung')).toBeVisible({ timeout: 10000 })
|
||||
})
|
||||
|
||||
// ------ Interview (Grenzen & Verwendung) ------
|
||||
test('interview tab loads', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${project.id}/interview`)
|
||||
await assertNoAppError(page)
|
||||
await expect(page.locator('h1')).toContainText('Grenzen & Verwendung', { timeout: 15000 })
|
||||
})
|
||||
|
||||
test('interview — form sections visible', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${project.id}/interview`)
|
||||
// The 6 collapsible section headers
|
||||
await expect(page.locator('text=Allgemeine Produktbeschreibung')).toBeVisible({ timeout: 10000 })
|
||||
await expect(page.locator('text=Bestimmungsgemasse Verwendung')).toBeVisible({ timeout: 10000 })
|
||||
await expect(page.locator('text=Vorhersehbare Fehlanwendung')).toBeVisible({ timeout: 10000 })
|
||||
await expect(page.locator('text=Grenzen der Maschine')).toBeVisible({ timeout: 10000 })
|
||||
await expect(page.locator('text=Schnittstellen')).toBeVisible({ timeout: 10000 })
|
||||
await expect(page.locator('text=Betroffene Personen')).toBeVisible({ timeout: 10000 })
|
||||
})
|
||||
|
||||
test('interview — pre-filled data visible', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${project.id}/interview`)
|
||||
// First section is open by default and shows pre-filled machine name
|
||||
await page.waitForTimeout(2000)
|
||||
const body = await page.innerText('body')
|
||||
// The machine name from the project should appear somewhere in the form
|
||||
// (either in the input or in a "Vorausgefuellt" help text)
|
||||
const hasProjectName = body.includes(project.name) || body.includes('Vorausgefuellt')
|
||||
expect(hasProjectName).toBeTruthy()
|
||||
})
|
||||
|
||||
test('interview — section collapse/expand works', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${project.id}/interview`)
|
||||
// "Bestimmungsgemasse Verwendung" section is collapsed by default
|
||||
// Click to expand it
|
||||
const sectionBtn = page.locator('button', { hasText: 'Bestimmungsgemasse Verwendung' })
|
||||
await expect(sectionBtn).toBeVisible({ timeout: 10000 })
|
||||
await sectionBtn.click()
|
||||
await page.waitForTimeout(500)
|
||||
// After expanding, we should see "Verwendungszweck" label inside
|
||||
await expect(page.locator('text=Verwendungszweck')).toBeVisible({ timeout: 10000 })
|
||||
// Click again to collapse
|
||||
await sectionBtn.click()
|
||||
await page.waitForTimeout(500)
|
||||
// Content should no longer be visible
|
||||
await expect(page.locator('label:has-text("Verwendungszweck")')).not.toBeVisible({ timeout: 3000 })
|
||||
})
|
||||
|
||||
test('interview — completion badge visible', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${project.id}/interview`)
|
||||
// CompletionBadge shows "X% ausgefuellt"
|
||||
await expect(page.locator('text=ausgefuellt')).toBeVisible({ timeout: 10000 })
|
||||
})
|
||||
|
||||
test('interview — navigation buttons present', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${project.id}/interview`)
|
||||
await expect(page.locator('text=Zurueck zur Uebersicht')).toBeVisible({ timeout: 10000 })
|
||||
await expect(page.locator('text=Weiter zu Komponenten')).toBeVisible({ timeout: 10000 })
|
||||
})
|
||||
|
||||
// ------ Hazards ------
|
||||
test('hazards tab loads', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${project.id}/hazards`)
|
||||
@@ -213,6 +370,41 @@ for (const project of PROJECTS) {
|
||||
).toBeVisible({ timeout: 10000 })
|
||||
})
|
||||
|
||||
test('hazards — default view is Risikobewertung', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${project.id}/hazards`)
|
||||
// The "Risikobewertung" button should be the active one (has bg-purple-600)
|
||||
const riskBtn = page.locator('button', { hasText: 'Risikobewertung' })
|
||||
await expect(riskBtn).toBeVisible({ timeout: 10000 })
|
||||
// Default state is 'risk', so the RiskAssessmentTable should be rendered
|
||||
// Wait for it to load
|
||||
await page.waitForTimeout(2000)
|
||||
// Check that the risk assessment table header is visible
|
||||
const body = await page.innerText('body')
|
||||
expect(body).toContain('Risikobewertungstabelle')
|
||||
})
|
||||
|
||||
test('hazards — S/E/P dropdowns visible with values > 1', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${project.id}/hazards`)
|
||||
// Default view is risk assessment — wait for table
|
||||
await page.waitForTimeout(3000)
|
||||
// RiskAssessmentTable renders <select> elements for S/E/P
|
||||
const selects = page.locator('select')
|
||||
await expect(selects.first()).toBeVisible({ timeout: 15000 })
|
||||
const selectCount = await selects.count()
|
||||
expect(selectCount).toBeGreaterThan(0)
|
||||
// Check that at least one select has a value > 1
|
||||
const firstValue = await selects.first().inputValue()
|
||||
const numValue = parseInt(firstValue, 10)
|
||||
expect(numValue).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
test('hazards — "Gefaehrdungen erkennen" button visible', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${project.id}/hazards`)
|
||||
await expect(
|
||||
page.locator('button', { hasText: 'Gefaehrdungen erkennen' })
|
||||
).toBeVisible({ timeout: 10000 })
|
||||
})
|
||||
|
||||
test('hazards — switch to risk assessment view', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${project.id}/hazards`)
|
||||
// Click the "Risikobewertung" toggle
|
||||
@@ -255,7 +447,7 @@ for (const project of PROJECTS) {
|
||||
await expect(page.locator('h1')).toContainText('Massnahmen', { timeout: 10000 })
|
||||
})
|
||||
|
||||
test('mitigations — 3-column layout visible', async ({ page }) => {
|
||||
test('mitigations — 3 accordion sections visible', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${project.id}/mitigations`)
|
||||
await expect(page.locator('text=Stufe 1: Design')).toBeVisible({ timeout: 10000 })
|
||||
await expect(page.locator('text=Stufe 2: Schutz')).toBeVisible({ timeout: 10000 })
|
||||
@@ -276,17 +468,44 @@ for (const project of PROJECTS) {
|
||||
).toBeVisible({ timeout: 10000 })
|
||||
})
|
||||
|
||||
test('mitigations — add buttons per column', async ({ page }) => {
|
||||
test('mitigations — checkbox selection works', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${project.id}/mitigations`)
|
||||
const addButtons = page.locator('button', { hasText: '+ Hinzufuegen' })
|
||||
const count = await addButtons.count()
|
||||
expect(count).toBe(3)
|
||||
await page.waitForTimeout(2000)
|
||||
// Find the first checkbox in the mitigations table rows
|
||||
const checkboxes = page.locator('input[type="checkbox"]')
|
||||
const count = await checkboxes.count()
|
||||
if (count > 0) {
|
||||
// Click the first non-header checkbox to select an item
|
||||
await checkboxes.first().click()
|
||||
await page.waitForTimeout(500)
|
||||
// After selecting, batch action buttons should appear: "ausgewaehlt" text
|
||||
const body = await page.innerText('body')
|
||||
expect(body).toContain('ausgewaehlt')
|
||||
}
|
||||
})
|
||||
|
||||
test('mitigations — "Massnahme hinzufuegen" button', async ({ page }) => {
|
||||
test('mitigations — batch buttons appear when items selected', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${project.id}/mitigations`)
|
||||
await page.waitForTimeout(2000)
|
||||
const checkboxes = page.locator('input[type="checkbox"]')
|
||||
const count = await checkboxes.count()
|
||||
if (count > 0) {
|
||||
await checkboxes.first().click()
|
||||
await page.waitForTimeout(500)
|
||||
// Batch action buttons: Verifizieren and Loeschen
|
||||
await expect(
|
||||
page.locator('button', { hasText: 'Verifizieren' })
|
||||
).toBeVisible({ timeout: 10000 })
|
||||
await expect(
|
||||
page.locator('button', { hasText: 'Loeschen' })
|
||||
).toBeVisible({ timeout: 10000 })
|
||||
}
|
||||
})
|
||||
|
||||
test('mitigations — add buttons visible', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${project.id}/mitigations`)
|
||||
await expect(
|
||||
page.locator('button', { hasText: 'Massnahme hinzufuegen' })
|
||||
page.locator('button', { hasText: '+ Hinzufuegen' })
|
||||
).toBeVisible({ timeout: 10000 })
|
||||
})
|
||||
|
||||
@@ -318,6 +537,31 @@ for (const project of PROJECTS) {
|
||||
await expect(page.locator('h1')).toContainText('CE-Akte', { timeout: 10000 })
|
||||
})
|
||||
|
||||
test('tech-file — "PDF exportieren" button visible', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${project.id}/tech-file`)
|
||||
await expect(
|
||||
page.locator('button', { hasText: 'PDF exportieren' })
|
||||
).toBeVisible({ timeout: 10000 })
|
||||
})
|
||||
|
||||
test('tech-file — "Excel exportieren" button visible', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${project.id}/tech-file`)
|
||||
await expect(
|
||||
page.locator('button', { hasText: 'Excel exportieren' })
|
||||
).toBeVisible({ timeout: 10000 })
|
||||
})
|
||||
|
||||
test('tech-file — progress bar and section list visible', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${project.id}/tech-file`)
|
||||
await page.waitForTimeout(2000)
|
||||
const body = await page.innerText('body')
|
||||
// Progress section always renders
|
||||
expect(body).toContain('Fortschritt')
|
||||
// Either sections are listed or the empty state shows
|
||||
const hasSections = body.includes('Generieren') || body.includes('Keine Abschnitte vorhanden')
|
||||
expect(hasSections).toBeTruthy()
|
||||
})
|
||||
|
||||
// ------ Monitoring ------
|
||||
test('monitoring tab loads', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${project.id}/monitoring`)
|
||||
@@ -326,3 +570,131 @@ for (const project of PROJECTS) {
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 10. Compliance Alerts — Cobot project specific
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe('Compliance Alerts — Cobot Project', () => {
|
||||
test.setTimeout(60_000)
|
||||
|
||||
test('compliance alerts show trigger count > 0', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${COBOT_PROJECT_ID}`)
|
||||
await page.waitForTimeout(4000)
|
||||
// ComplianceAlerts shows "X Compliance-Hinweise erkannt"
|
||||
const alertsHeader = page.locator('text=Compliance-Hinweise erkannt')
|
||||
if (await alertsHeader.isVisible({ timeout: 10000 })) {
|
||||
const headerText = await alertsHeader.innerText()
|
||||
// Extract the count from "X Compliance-Hinweise erkannt"
|
||||
const match = headerText.match(/(\d+)/)
|
||||
expect(match).not.toBeNull()
|
||||
if (match) {
|
||||
expect(parseInt(match[1], 10)).toBeGreaterThan(0)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('compliance alerts — regulation badges visible', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${COBOT_PROJECT_ID}`)
|
||||
await page.waitForTimeout(4000)
|
||||
const body = await page.innerText('body')
|
||||
if (body.includes('Compliance-Hinweise erkannt')) {
|
||||
// At least some of: DSGVO, AI Act, CRA, NIS2, Data Act
|
||||
const hasDSGVO = body.includes('DSGVO')
|
||||
const hasAIAct = body.includes('AI Act')
|
||||
const hasCRA = body.includes('CRA')
|
||||
expect(hasDSGVO || hasAIAct || hasCRA).toBeTruthy()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 11. Production Lines
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe('Production Lines', () => {
|
||||
test.setTimeout(60_000)
|
||||
|
||||
test('lines list page loads', async ({ page }) => {
|
||||
await goTo(page, '/sdk/iace/lines')
|
||||
await assertNoAppError(page)
|
||||
await expect(page.locator('h1')).toContainText('Produktionslinien', { timeout: 15000 })
|
||||
})
|
||||
|
||||
test('lines — "Neue Produktionslinie" button visible', async ({ page }) => {
|
||||
await goTo(page, '/sdk/iace/lines')
|
||||
await expect(
|
||||
page.locator('button', { hasText: 'Neue Produktionslinie' })
|
||||
).toBeVisible({ timeout: 10000 })
|
||||
})
|
||||
|
||||
test('lines — "Fertigungsstrasse Halle 3" visible', async ({ page }) => {
|
||||
await goTo(page, '/sdk/iace/lines')
|
||||
await page.waitForTimeout(3000)
|
||||
const body = await page.innerText('body')
|
||||
// The seeded line should appear in the list
|
||||
expect(body).toContain('Fertigungsstrasse Halle 3')
|
||||
})
|
||||
|
||||
test('line dashboard loads with stations', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/lines/${PRODUCTION_LINE_ID}`)
|
||||
await assertNoAppError(page)
|
||||
await page.waitForTimeout(3000)
|
||||
// The dashboard should show "Stationsuebersicht" heading
|
||||
await expect(
|
||||
page.locator('text=Stationsuebersicht')
|
||||
).toBeVisible({ timeout: 15000 })
|
||||
})
|
||||
|
||||
test('line dashboard — station cards rendered', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/lines/${PRODUCTION_LINE_ID}`)
|
||||
await page.waitForTimeout(3000)
|
||||
// The dashboard renders StationCard components with project machine names
|
||||
// At least one station should be present
|
||||
const body = await page.innerText('body')
|
||||
expect(body).toContain('Stationsuebersicht')
|
||||
// Check that we have "Alle Produktionslinien" back link
|
||||
await expect(
|
||||
page.locator('text=Alle Produktionslinien')
|
||||
).toBeVisible({ timeout: 10000 })
|
||||
})
|
||||
|
||||
test('line dashboard — back link to lines list', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/lines/${PRODUCTION_LINE_ID}`)
|
||||
const backLink = page.locator('a', { hasText: 'Alle Produktionslinien' })
|
||||
await expect(backLink).toBeVisible({ timeout: 10000 })
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 12. Normenrecherche — large norm count
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe('Normenrecherche — Cobot Project', () => {
|
||||
test.setTimeout(60_000)
|
||||
|
||||
test('normenrecherche shows large norm count', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${COBOT_PROJECT_ID}`)
|
||||
await page.waitForTimeout(4000)
|
||||
const body = await page.innerText('body')
|
||||
// SuggestedNorms shows "Normenrecherche — X relevante Normen"
|
||||
if (body.includes('Normenrecherche')) {
|
||||
const match = body.match(/Normenrecherche\s*[—-]\s*(\d+)\s*relevante Normen/)
|
||||
if (match) {
|
||||
const normCount = parseInt(match[1], 10)
|
||||
// Should be a substantial number (not just 215 from the old fallback)
|
||||
expect(normCount).toBeGreaterThan(100)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('normenrecherche — add norm input visible', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${COBOT_PROJECT_ID}`)
|
||||
await page.waitForTimeout(4000)
|
||||
// The "Weitere Norm ergaenzen" section has an input with ISO placeholder
|
||||
const addInput = page.locator('input[placeholder*="ISO 13857"]')
|
||||
if (await addInput.count() > 0) {
|
||||
await expect(addInput.first()).toBeVisible({ timeout: 10000 })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -212,6 +212,7 @@ async def _check_single_document(entry: DocCheckEntry) -> list[DocCheckResult]:
|
||||
all_results.append(main_result)
|
||||
|
||||
# Sub-section checks (auto-detected from headings)
|
||||
# Pass full doc_text for LLM verification fallback
|
||||
for section in sections:
|
||||
if section["word_count"] < 100:
|
||||
continue
|
||||
@@ -219,6 +220,7 @@ async def _check_single_document(entry: DocCheckEntry) -> list[DocCheckResult]:
|
||||
section["text"], section["doc_type"],
|
||||
section["title"], entry.url,
|
||||
section["word_count"],
|
||||
full_text=doc_text,
|
||||
)
|
||||
all_results.append(sub_result)
|
||||
|
||||
@@ -232,8 +234,16 @@ async def _check_single_document(entry: DocCheckEntry) -> list[DocCheckResult]:
|
||||
)]
|
||||
|
||||
|
||||
async def _run_checklist(text: str, doc_type: str, label: str, url: str, word_count: int = 0) -> DocCheckResult:
|
||||
"""Run checklist against text, then LLM-verify failed checks."""
|
||||
async def _run_checklist(
|
||||
text: str, doc_type: str, label: str, url: str,
|
||||
word_count: int = 0, full_text: str = "",
|
||||
) -> DocCheckResult:
|
||||
"""Run checklist against text, then LLM-verify failed checks.
|
||||
|
||||
Args:
|
||||
full_text: Optional full document text for LLM verification.
|
||||
If empty, uses `text` (the section fragment).
|
||||
"""
|
||||
findings = check_document_completeness(text, doc_type, label, url)
|
||||
|
||||
all_checks: list[CheckItem] = []
|
||||
@@ -259,7 +269,7 @@ async def _run_checklist(text: str, doc_type: str, label: str, url: str, word_co
|
||||
try:
|
||||
from compliance.services.doc_checks.llm_verify import verify_failed_checks
|
||||
overturns = await verify_failed_checks(
|
||||
text,
|
||||
full_text or text,
|
||||
[{"id": c.id, "label": c.label, "hint": c.hint} for c in failed],
|
||||
label,
|
||||
)
|
||||
@@ -338,31 +348,30 @@ def _split_into_sections(text: str, parent_label: str, url: str) -> list[dict]:
|
||||
"word_count": len(sec_text.split()),
|
||||
})
|
||||
|
||||
prev_blank = False
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
# Only split at headings that classify as a known document type.
|
||||
# This prevents table content ("Funktionale Cookies", "Typen")
|
||||
# from triggering section splits.
|
||||
is_heading = (
|
||||
5 < len(stripped) < 80
|
||||
and not stripped.endswith(".")
|
||||
and not stripped.endswith(",")
|
||||
and stripped[0].isupper()
|
||||
# Require preceding blank line to distinguish real headings
|
||||
# from table content ("Funktionale Cookies", "Session Cookies")
|
||||
and prev_blank
|
||||
)
|
||||
is_skip = is_heading and stripped.lower().strip() in SKIP_HEADINGS
|
||||
classified = _classify_section(stripped) if is_heading else None
|
||||
is_real_heading = is_heading and classified is not None
|
||||
is_skip = is_real_heading and stripped.lower().strip() in SKIP_HEADINGS
|
||||
|
||||
if is_heading and not is_skip and current_heading:
|
||||
if is_real_heading and not is_skip and current_heading:
|
||||
_save_section(current_heading, current_text)
|
||||
|
||||
if is_heading and not is_skip:
|
||||
if is_real_heading and not is_skip:
|
||||
current_heading = stripped
|
||||
current_text = []
|
||||
else:
|
||||
current_text.append(line)
|
||||
|
||||
prev_blank = len(stripped) == 0
|
||||
|
||||
# Last section
|
||||
if current_heading:
|
||||
_save_section(current_heading, current_text)
|
||||
|
||||
Reference in New Issue
Block a user