feat: Custom Hazard Modal + Residual Risk Panel

- CustomHazardModal: Eigene Gefaehrdung erstellen mit S/E/P/A Slidern
- ResidualRiskPanel: Akzeptabel-Toggle pro Hazard + Fortschrittsbalken
- RiskAssessmentTable: Accept/Reject Buttons pro Zeile integriert

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-05-07 16:09:50 +02:00
parent 5244500af6
commit a93ba9ee40
4 changed files with 386 additions and 8 deletions
@@ -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>
)
}
@@ -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 ? (
{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'
}`}>&#10003;</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'
}`}>&#10007;</button>
</div>
) : (
afterRpz <= 20 ? (
<span className="inline-block w-4 h-4 rounded-full bg-green-500 text-white text-[10px] leading-4 text-center" title="Akzeptabel">&#10003;</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">&#8776;</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">&#10007;</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} />
)