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:
@@ -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>
|
||||
)
|
||||
}
|
||||
+25
-2
@@ -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'
|
||||
}`}>✓</button>
|
||||
<button onClick={() => onDecision(h.id, decisions?.[h.id] === false ? null : false)}
|
||||
title="Nicht akzeptabel"
|
||||
className={`w-5 h-5 rounded-full text-[10px] leading-5 text-center transition-colors ${
|
||||
decisions?.[h.id] === false
|
||||
? 'bg-red-500 text-white ring-2 ring-red-300'
|
||||
: 'bg-gray-200 dark:bg-gray-600 text-gray-500 dark:text-gray-400 hover:bg-red-200'
|
||||
}`}>✗</button>
|
||||
</div>
|
||||
) : (
|
||||
afterRpz <= 20 ? (
|
||||
<span className="inline-block w-4 h-4 rounded-full bg-green-500 text-white text-[10px] leading-4 text-center" title="Akzeptabel">✓</span>
|
||||
) : afterRpz <= 60 ? (
|
||||
<span className="inline-block w-4 h-4 rounded-full bg-yellow-400 text-yellow-900 text-[10px] leading-4 text-center" title="Bedingt">≈</span>
|
||||
) : (
|
||||
<span className="inline-block w-4 h-4 rounded-full bg-red-500 text-white text-[10px] leading-4 text-center" title="Nicht akzeptabel">✗</span>
|
||||
)
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -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} />
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user