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
|
projectId: string
|
||||||
hazards: Hazard[]
|
hazards: Hazard[]
|
||||||
onReassess?: () => void
|
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. */
|
/** Editable S/E/P/A state per hazard for the "after measures" column. */
|
||||||
@@ -72,7 +76,7 @@ function InlineSelect({ value, onChange, label }: {
|
|||||||
// Main component
|
// 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 [mitCounts, setMitCounts] = useState<Record<string, number>>({})
|
||||||
const [edits, setEdits] = useState<Record<string, EditState>>({})
|
const [edits, setEdits] = useState<Record<string, EditState>>({})
|
||||||
const [saving, setSaving] = useState<string | null>(null)
|
const [saving, setSaving] = useState<string | null>(null)
|
||||||
@@ -249,12 +253,31 @@ export function RiskAssessmentTable({ projectId, hazards, onReassess }: RiskAsse
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-2 py-2 text-center">
|
<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>
|
<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 ? (
|
) : 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-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>
|
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import React, { useState } from 'react'
|
import React, { useState, useMemo, useCallback } from 'react'
|
||||||
import { useParams } from 'next/navigation'
|
import { useParams } from 'next/navigation'
|
||||||
import { HazardForm } from './_components/HazardForm'
|
import { HazardForm } from './_components/HazardForm'
|
||||||
import { HazardTable } from './_components/HazardTable'
|
import { HazardTable } from './_components/HazardTable'
|
||||||
import { RiskAssessmentTable } from './_components/RiskAssessmentTable'
|
import { RiskAssessmentTable } from './_components/RiskAssessmentTable'
|
||||||
|
import { ResidualRiskPanel, getResidualStatus } from './_components/ResidualRiskPanel'
|
||||||
|
import type { ResidualFilter } from './_components/ResidualRiskPanel'
|
||||||
import { LibraryModal } from './_components/LibraryModal'
|
import { LibraryModal } from './_components/LibraryModal'
|
||||||
import { AutoSuggestPanel } from './_components/AutoSuggestPanel'
|
import { AutoSuggestPanel } from './_components/AutoSuggestPanel'
|
||||||
|
import { CustomHazardModal } from './_components/CustomHazardModal'
|
||||||
import { useHazards } from './_hooks/useHazards'
|
import { useHazards } from './_hooks/useHazards'
|
||||||
|
|
||||||
type ViewMode = 'list' | 'risk'
|
type ViewMode = 'list' | 'risk'
|
||||||
@@ -16,6 +19,30 @@ export default function HazardsPage() {
|
|||||||
const projectId = params.projectId as string
|
const projectId = params.projectId as string
|
||||||
const h = useHazards(projectId)
|
const h = useHazards(projectId)
|
||||||
const [view, setView] = useState<ViewMode>('risk')
|
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) {
|
if (h.loading) {
|
||||||
return (
|
return (
|
||||||
@@ -64,6 +91,13 @@ export default function HazardsPage() {
|
|||||||
</svg>
|
</svg>
|
||||||
Aus Bibliothek
|
Aus Bibliothek
|
||||||
</button>
|
</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)}
|
<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">
|
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">
|
<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)} />
|
<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 ? (
|
{h.hazards.length > 0 ? (
|
||||||
view === 'risk' ? (
|
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} />
|
<HazardTable hazards={h.hazards} lifecyclePhases={h.lifecyclePhases} onDelete={h.handleDelete} />
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user