refactor(admin): split evidence, process-tasks, iace/hazards pages
Extract components and hooks into _components/ and _hooks/ subdirectories to reduce each page.tsx to under 500 LOC (was 1545/1383/1316). Final line counts: evidence=213, process-tasks=304, hazards=157. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { MatchOutput, CATEGORY_LABELS } from './types'
|
||||
|
||||
export function AutoSuggestPanel({ matchResult, applying, onApply, onClose }: {
|
||||
projectId: string
|
||||
matchResult: MatchOutput
|
||||
applying: boolean
|
||||
onApply: (acceptedHazardCats: string[], acceptedMeasureIds: string[], acceptedEvidenceIds: string[], patternIds: string[]) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [selectedHazards, setSelectedHazards] = useState<Set<string>>(
|
||||
new Set(matchResult.suggested_hazards.map(h => h.category))
|
||||
)
|
||||
const [selectedMeasures, setSelectedMeasures] = useState<Set<string>>(
|
||||
new Set(matchResult.suggested_measures.map(m => m.measure_id))
|
||||
)
|
||||
const [selectedEvidence, setSelectedEvidence] = useState<Set<string>>(
|
||||
new Set(matchResult.suggested_evidence.map(e => e.evidence_id))
|
||||
)
|
||||
|
||||
function toggle<T>(set: Set<T>, setSet: (s: Set<T>) => void, key: T) {
|
||||
const next = new Set(set)
|
||||
if (next.has(key)) next.delete(key)
|
||||
else next.add(key)
|
||||
setSet(next)
|
||||
}
|
||||
|
||||
const totalSelected = selectedHazards.size + selectedMeasures.size + selectedEvidence.size
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border-2 border-purple-300 p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Pattern-Matching Ergebnisse</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{matchResult.matched_patterns.length} Patterns erkannt, {matchResult.resolved_tags.length} Tags aufgeloest
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1 text-gray-400 hover:text-gray-600 rounded">
|
||||
<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>
|
||||
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||
Erkannte Patterns ({matchResult.matched_patterns.length})
|
||||
</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{matchResult.matched_patterns.sort((a, b) => b.priority - a.priority).map(p => (
|
||||
<span key={p.pattern_id} className="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs bg-purple-50 text-purple-700 border border-purple-200">
|
||||
<span className="font-mono">{p.pattern_id}</span>
|
||||
<span>{p.pattern_name}</span>
|
||||
<span className="text-purple-400">P:{p.priority}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<div className="border border-orange-200 rounded-lg p-3 bg-orange-50/50">
|
||||
<h4 className="text-sm font-semibold text-orange-800 mb-2">Gefaehrdungen ({matchResult.suggested_hazards.length})</h4>
|
||||
<div className="space-y-2 max-h-48 overflow-auto">
|
||||
{matchResult.suggested_hazards.map(h => (
|
||||
<label key={h.category} className="flex items-start gap-2 cursor-pointer">
|
||||
<input type="checkbox" checked={selectedHazards.has(h.category)}
|
||||
onChange={() => toggle(selectedHazards, setSelectedHazards, h.category)}
|
||||
className="mt-0.5 accent-purple-600" />
|
||||
<div>
|
||||
<div className="text-xs font-medium text-gray-900">{CATEGORY_LABELS[h.category] || h.category}</div>
|
||||
<div className="text-xs text-gray-500">Konfidenz: {Math.round(h.confidence * 100)}%</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-green-200 rounded-lg p-3 bg-green-50/50">
|
||||
<h4 className="text-sm font-semibold text-green-800 mb-2">Massnahmen ({matchResult.suggested_measures.length})</h4>
|
||||
<div className="space-y-2 max-h-48 overflow-auto">
|
||||
{matchResult.suggested_measures.map(m => (
|
||||
<label key={m.measure_id} className="flex items-start gap-2 cursor-pointer">
|
||||
<input type="checkbox" checked={selectedMeasures.has(m.measure_id)}
|
||||
onChange={() => toggle(selectedMeasures, setSelectedMeasures, m.measure_id)}
|
||||
className="mt-0.5 accent-purple-600" />
|
||||
<div>
|
||||
<div className="text-xs font-medium text-gray-900 font-mono">{m.measure_id}</div>
|
||||
<div className="text-xs text-gray-500">von {m.source_patterns.length} Pattern(s)</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-blue-200 rounded-lg p-3 bg-blue-50/50">
|
||||
<h4 className="text-sm font-semibold text-blue-800 mb-2">Nachweise ({matchResult.suggested_evidence.length})</h4>
|
||||
<div className="space-y-2 max-h-48 overflow-auto">
|
||||
{matchResult.suggested_evidence.map(e => (
|
||||
<label key={e.evidence_id} className="flex items-start gap-2 cursor-pointer">
|
||||
<input type="checkbox" checked={selectedEvidence.has(e.evidence_id)}
|
||||
onChange={() => toggle(selectedEvidence, setSelectedEvidence, e.evidence_id)}
|
||||
className="mt-0.5 accent-purple-600" />
|
||||
<div>
|
||||
<div className="text-xs font-medium text-gray-900 font-mono">{e.evidence_id}</div>
|
||||
<div className="text-xs text-gray-500">von {e.source_patterns.length} Pattern(s)</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{matchResult.resolved_tags.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-gray-500 mb-1">Aufgeloeste Tags</h4>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{matchResult.resolved_tags.map(tag => (
|
||||
<span key={tag} className="text-xs px-1.5 py-0.5 rounded bg-gray-100 text-gray-500">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between pt-2 border-t border-gray-200">
|
||||
<span className="text-sm text-gray-500">{totalSelected} Elemente ausgewaehlt</span>
|
||||
<div className="flex gap-3">
|
||||
<button onClick={onClose} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onApply(
|
||||
Array.from(selectedHazards),
|
||||
Array.from(selectedMeasures),
|
||||
Array.from(selectedEvidence),
|
||||
matchResult.matched_patterns.map(p => p.pattern_id),
|
||||
)}
|
||||
disabled={totalSelected === 0 || applying}
|
||||
className={`px-6 py-2 rounded-lg font-medium transition-colors ${totalSelected > 0 && !applying ? 'bg-purple-600 text-white hover:bg-purple-700' : 'bg-gray-200 text-gray-400 cursor-not-allowed'}`}>
|
||||
{applying ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white" />
|
||||
Wird uebernommen...
|
||||
</span>
|
||||
) : `${totalSelected} uebernehmen`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,27 +1,37 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { HazardFormData, HAZARD_CATEGORIES, CATEGORY_LABELS, getRiskLevel, getRiskColor } from './types'
|
||||
import {
|
||||
HazardFormData, LifecyclePhase, RoleInfo,
|
||||
HAZARD_CATEGORIES, CATEGORY_LABELS, getRiskColor, getRiskLevelISO, getRiskLevelLegacy,
|
||||
} from './types'
|
||||
import { RiskBadge } from './RiskBadge'
|
||||
|
||||
interface HazardFormProps {
|
||||
export function HazardForm({
|
||||
onSubmit,
|
||||
onCancel,
|
||||
lifecyclePhases,
|
||||
roles,
|
||||
}: {
|
||||
onSubmit: (data: HazardFormData) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export function HazardForm({ onSubmit, onCancel }: HazardFormProps) {
|
||||
lifecyclePhases: LifecyclePhase[]
|
||||
roles: RoleInfo[]
|
||||
}) {
|
||||
const [formData, setFormData] = useState<HazardFormData>({
|
||||
name: '',
|
||||
description: '',
|
||||
category: 'mechanical',
|
||||
component_id: '',
|
||||
severity: 3,
|
||||
exposure: 3,
|
||||
probability: 3,
|
||||
name: '', description: '', category: 'mechanical_hazard', component_id: '',
|
||||
severity: 3, exposure: 3, probability: 3, avoidance: 3,
|
||||
lifecycle_phase: '', trigger_event: '', affected_person: '',
|
||||
possible_harm: '', hazardous_zone: '', machine_module: '',
|
||||
})
|
||||
const [showExtended, setShowExtended] = useState(false)
|
||||
|
||||
const rInherent = formData.severity * formData.exposure * formData.probability
|
||||
const riskLevel = getRiskLevel(rInherent)
|
||||
const isISOMode = formData.avoidance > 0
|
||||
const rInherent = isISOMode
|
||||
? formData.severity * formData.exposure * formData.probability * formData.avoidance
|
||||
: formData.severity * formData.exposure * formData.probability
|
||||
const riskLevel = isISOMode ? getRiskLevelISO(rInherent) : getRiskLevelLegacy(rInherent)
|
||||
const formulaLabel = isISOMode ? 'R = S × F × P × A' : 'R = S × E × P'
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||
@@ -30,77 +40,93 @@ export function HazardForm({ onSubmit, onCancel }: HazardFormProps) {
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Bezeichnung *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
<input type="text" value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="z.B. Quetschung durch Roboterarm"
|
||||
className="w-full px-4 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"
|
||||
/>
|
||||
className="w-full px-4 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" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Kategorie</label>
|
||||
<select
|
||||
value={formData.category}
|
||||
onChange={(e) => setFormData({ ...formData, category: e.target.value })}
|
||||
className="w-full px-4 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"
|
||||
>
|
||||
{HAZARD_CATEGORIES.map((cat) => (
|
||||
<option key={cat} value={cat}>{CATEGORY_LABELS[cat] || cat}</option>
|
||||
))}
|
||||
<select value={formData.category} onChange={(e) => setFormData({ ...formData, category: e.target.value })}
|
||||
className="w-full px-4 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">
|
||||
{HAZARD_CATEGORIES.map((cat) => <option key={cat} value={cat}>{CATEGORY_LABELS[cat] || cat}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Beschreibung</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
rows={2}
|
||||
placeholder="Detaillierte Beschreibung der Gefaehrdung..."
|
||||
className="w-full px-4 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"
|
||||
/>
|
||||
<textarea value={formData.description} onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
rows={2} placeholder="Detaillierte Beschreibung der Gefaehrdung..."
|
||||
className="w-full px-4 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" />
|
||||
</div>
|
||||
|
||||
<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">Risikobewertung (S x E x P)</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-1">
|
||||
Schwere (S): <span className="font-bold">{formData.severity}</span>
|
||||
</label>
|
||||
<input type="range" min={1} max={5} value={formData.severity}
|
||||
onChange={(e) => setFormData({ ...formData, severity: Number(e.target.value) })}
|
||||
className="w-full accent-purple-600"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-400"><span>Gering</span><span>Toedlich</span></div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-1">
|
||||
Exposition (E): <span className="font-bold">{formData.exposure}</span>
|
||||
</label>
|
||||
<input type="range" min={1} max={5} value={formData.exposure}
|
||||
onChange={(e) => setFormData({ ...formData, exposure: Number(e.target.value) })}
|
||||
className="w-full accent-purple-600"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-400"><span>Selten</span><span>Staendig</span></div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-1">
|
||||
Wahrscheinlichkeit (P): <span className="font-bold">{formData.probability}</span>
|
||||
</label>
|
||||
<input type="range" min={1} max={5} value={formData.probability}
|
||||
onChange={(e) => setFormData({ ...formData, probability: Number(e.target.value) })}
|
||||
className="w-full accent-purple-600"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-400"><span>Unwahrscheinlich</span><span>Sehr wahrscheinlich</span></div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Lebensphase</label>
|
||||
<select value={formData.lifecycle_phase} onChange={(e) => setFormData({ ...formData, lifecycle_phase: e.target.value })}
|
||||
className="w-full px-4 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">
|
||||
<option value="">-- Keine Auswahl --</option>
|
||||
{lifecyclePhases.map((p) => <option key={p.id} value={p.id}>{p.label_de}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Betroffene Personen</label>
|
||||
<select value={formData.affected_person} onChange={(e) => setFormData({ ...formData, affected_person: e.target.value })}
|
||||
className="w-full px-4 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">
|
||||
<option value="">-- Bitte waehlen --</option>
|
||||
{roles.map((r) => <option key={r.id} value={r.id}>{r.label_de}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" onClick={() => setShowExtended(!showExtended)}
|
||||
className="text-sm text-purple-600 hover:text-purple-700 font-medium">
|
||||
{showExtended ? 'Weniger Felder anzeigen' : 'Weitere Felder anzeigen (Ausloeser, Gefahrenzone, Modul...)'}
|
||||
</button>
|
||||
|
||||
{showExtended && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 p-4 bg-gray-50 dark:bg-gray-750 rounded-lg">
|
||||
{[
|
||||
{ label: 'Ausloeseereignis', field: 'trigger_event' as const, placeholder: 'z.B. Schutztuer offen bei Betrieb' },
|
||||
{ label: 'Moeglicher Schaden', field: 'possible_harm' as const, placeholder: 'z.B. Schwere Quetschverletzung' },
|
||||
{ label: 'Gefahrenzone', field: 'hazardous_zone' as const, placeholder: 'z.B. Roboter-Arbeitsbereich' },
|
||||
{ label: 'Maschinenmodul', field: 'machine_module' as const, placeholder: 'z.B. Antriebseinheit' },
|
||||
].map(({ label, field, placeholder }) => (
|
||||
<div key={field}>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{label}</label>
|
||||
<input type="text" value={formData[field] as string}
|
||||
onChange={(e) => setFormData({ ...formData, [field]: e.target.value })}
|
||||
placeholder={placeholder}
|
||||
className="w-full px-4 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" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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">Risikobewertung ({formulaLabel})</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{[
|
||||
{ label: 'Schwere (S)', field: 'severity' as const, low: 'Gering', high: 'Toedlich' },
|
||||
{ label: 'Haeufigkeit (F)', field: 'exposure' as const, low: 'Selten', high: 'Staendig' },
|
||||
{ label: 'Wahrscheinlichkeit (P)', field: 'probability' as const, low: 'Unwahrscheinlich', high: 'Sehr wahrscheinlich' },
|
||||
{ label: 'Vermeidbarkeit (A)', field: 'avoidance' as const, low: 'Leicht', high: 'Unmoeglich' },
|
||||
].map(({ label, field, low, high }) => (
|
||||
<div key={field}>
|
||||
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-1">
|
||||
{label}: <span className="font-bold">{formData[field]}</span>
|
||||
</label>
|
||||
<input type="range" min={1} max={5} value={formData[field]}
|
||||
onChange={(e) => setFormData({ ...formData, [field]: Number(e.target.value) })}
|
||||
className="w-full accent-purple-600" />
|
||||
<div className="flex justify-between text-xs text-gray-400"><span>{low}</span><span>{high}</span></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className={`mt-4 p-3 rounded-lg border ${getRiskColor(riskLevel)}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">R_inherent = S x E x P</span>
|
||||
<span className="text-sm font-medium">{formulaLabel}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg font-bold">{rInherent}</span>
|
||||
<RiskBadge level={riskLevel} />
|
||||
@@ -111,13 +137,8 @@ export function HazardForm({ onSubmit, onCancel }: HazardFormProps) {
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => onSubmit(formData)}
|
||||
disabled={!formData.name}
|
||||
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
|
||||
formData.name ? 'bg-purple-600 text-white hover:bg-purple-700' : 'bg-gray-200 text-gray-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
<button onClick={() => onSubmit(formData)} disabled={!formData.name}
|
||||
className={`px-6 py-2 rounded-lg font-medium transition-colors ${formData.name ? 'bg-purple-600 text-white hover:bg-purple-700' : 'bg-gray-200 text-gray-400 cursor-not-allowed'}`}>
|
||||
Hinzufuegen
|
||||
</button>
|
||||
<button onClick={onCancel} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import { Hazard, CATEGORY_LABELS, STATUS_LABELS } from './types'
|
||||
import { RiskBadge } from './RiskBadge'
|
||||
import { Hazard, LifecyclePhase, CATEGORY_LABELS, STATUS_LABELS } from './types'
|
||||
import { RiskBadge, ReviewStatusBadge } from './RiskBadge'
|
||||
|
||||
interface HazardTableProps {
|
||||
export function HazardTable({ hazards, lifecyclePhases, onDelete }: {
|
||||
hazards: Hazard[]
|
||||
lifecyclePhases: LifecyclePhase[]
|
||||
onDelete: (id: string) => void
|
||||
}
|
||||
|
||||
export function HazardTable({ hazards, onDelete }: HazardTableProps) {
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
@@ -17,42 +16,52 @@ export function HazardTable({ hazards, onDelete }: HazardTableProps) {
|
||||
<tr className="bg-gray-50 dark:bg-gray-750 border-b border-gray-200 dark:border-gray-700">
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Bezeichnung</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Kategorie</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Komponente</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">S</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">E</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">F</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">P</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">A</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">R</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Risiko</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Review</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{hazards
|
||||
.sort((a, b) => b.r_inherent - a.r_inherent)
|
||||
.sort((a, b) => (b.r_inherent || 0) - (a.r_inherent || 0))
|
||||
.map((hazard) => (
|
||||
<tr key={hazard.id} className="hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors">
|
||||
<td className="px-4 py-3">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">{hazard.name}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">{hazard.name}</div>
|
||||
{hazard.name.startsWith('Auto:') && (
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-green-100 text-green-700">Auto</span>
|
||||
)}
|
||||
</div>
|
||||
{hazard.description && (
|
||||
<div className="text-xs text-gray-500 truncate max-w-[250px]">{hazard.description}</div>
|
||||
)}
|
||||
{hazard.lifecycle_phase && (
|
||||
<div className="text-xs text-purple-500 mt-0.5">
|
||||
{lifecyclePhases.find(p => p.id === hazard.lifecycle_phase)?.label_de || hazard.lifecycle_phase}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600">{CATEGORY_LABELS[hazard.category] || hazard.category}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600">{hazard.component_name || '--'}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-900 dark:text-white text-center font-medium">{hazard.severity}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-900 dark:text-white text-center font-medium">{hazard.exposure}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-900 dark:text-white text-center font-medium">{hazard.probability}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-900 dark:text-white text-center font-medium">{hazard.avoidance || '-'}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-900 dark:text-white text-center font-bold">{hazard.r_inherent}</td>
|
||||
<td className="px-4 py-3"><RiskBadge level={hazard.risk_level} /></td>
|
||||
<td className="px-4 py-3"><ReviewStatusBadge status={hazard.review_status || 'draft'} /></td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-xs text-gray-500">{STATUS_LABELS[hazard.status] || hazard.status}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button
|
||||
onClick={() => onDelete(hazard.id)}
|
||||
className="p-1 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded transition-colors"
|
||||
>
|
||||
<button onClick={() => onDelete(hazard.id)}
|
||||
className="p-1 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded transition-colors">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
|
||||
@@ -3,15 +3,14 @@
|
||||
import { useState } from 'react'
|
||||
import { LibraryHazard, HAZARD_CATEGORIES, CATEGORY_LABELS } from './types'
|
||||
|
||||
interface LibraryModalProps {
|
||||
export function LibraryModal({ library, onAdd, onClose }: {
|
||||
library: LibraryHazard[]
|
||||
onAdd: (item: LibraryHazard) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function LibraryModal({ library, onAdd, onClose }: LibraryModalProps) {
|
||||
}) {
|
||||
const [search, setSearch] = useState('')
|
||||
const [filterCat, setFilterCat] = useState('')
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||
|
||||
const filtered = library.filter((h) => {
|
||||
const matchSearch = !search || h.name.toLowerCase().includes(search.toLowerCase()) || h.description.toLowerCase().includes(search.toLowerCase())
|
||||
@@ -21,10 +20,10 @@ export function LibraryModal({ library, onAdd, onClose }: LibraryModalProps) {
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl w-full max-w-2xl max-h-[80vh] flex flex-col">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl w-full max-w-3xl max-h-[85vh] flex flex-col">
|
||||
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Gefaehrdungsbibliothek</h3>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Gefaehrdungsbibliothek ({filtered.length} Eintraege)</h3>
|
||||
<button onClick={onClose} className="p-1 text-gray-400 hover:text-gray-600 rounded">
|
||||
<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" />
|
||||
@@ -32,43 +31,61 @@ export function LibraryModal({ library, onAdd, onClose }: LibraryModalProps) {
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
<input type="text" value={search} onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Suchen..."
|
||||
className="flex-1 px-4 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"
|
||||
/>
|
||||
<select
|
||||
value={filterCat}
|
||||
onChange={(e) => setFilterCat(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
>
|
||||
className="flex-1 px-4 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" />
|
||||
<select value={filterCat} onChange={(e) => setFilterCat(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white">
|
||||
<option value="">Alle Kategorien</option>
|
||||
{HAZARD_CATEGORIES.map((cat) => (
|
||||
<option key={cat} value={cat}>{CATEGORY_LABELS[cat] || cat}</option>
|
||||
))}
|
||||
{HAZARD_CATEGORIES.map((cat) => <option key={cat} value={cat}>{CATEGORY_LABELS[cat] || cat}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto p-4 space-y-2">
|
||||
{filtered.length > 0 ? (
|
||||
filtered.map((item) => (
|
||||
<div key={item.id} className="flex items-center justify-between p-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-750">
|
||||
<div className="flex-1 min-w-0 mr-3">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">{item.name}</div>
|
||||
<div className="text-xs text-gray-500 truncate">{item.description}</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-xs text-gray-400">{CATEGORY_LABELS[item.category] || item.category}</span>
|
||||
<span className="text-xs text-gray-400">S:{item.default_severity} E:{item.default_exposure} P:{item.default_probability}</span>
|
||||
<div key={item.id} className="rounded-lg border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-750">
|
||||
<div className="flex items-center justify-between p-3">
|
||||
<div className="flex-1 min-w-0 mr-3 cursor-pointer"
|
||||
onClick={() => setExpandedId(expandedId === item.id ? null : item.id)}>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">{item.name}</div>
|
||||
<div className="text-xs text-gray-500 truncate">{item.description}</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-xs text-gray-400">{CATEGORY_LABELS[item.category] || item.category}</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
S:{item.default_severity} F:{item.default_exposure || 3} P:{item.default_probability} A:{item.default_avoidance || 3}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => onAdd(item)}
|
||||
className="flex-shrink-0 px-3 py-1.5 text-xs bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors">
|
||||
Hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onAdd(item)}
|
||||
className="flex-shrink-0 px-3 py-1.5 text-xs bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
Hinzufuegen
|
||||
</button>
|
||||
{expandedId === item.id && (
|
||||
<div className="px-3 pb-3 space-y-2 text-xs">
|
||||
{item.typical_causes && item.typical_causes.length > 0 && (
|
||||
<div><span className="font-medium text-gray-600">Typische Ursachen: </span>
|
||||
<span className="text-gray-500">{item.typical_causes.join(', ')}</span></div>
|
||||
)}
|
||||
{item.typical_harm && (
|
||||
<div><span className="font-medium text-gray-600">Typischer Schaden: </span>
|
||||
<span className="text-gray-500">{item.typical_harm}</span></div>
|
||||
)}
|
||||
{item.recommended_measures_design && item.recommended_measures_design.length > 0 && (
|
||||
<div><span className="font-medium text-blue-600">Konstruktiv: </span>
|
||||
<span className="text-gray-500">{item.recommended_measures_design.join(', ')}</span></div>
|
||||
)}
|
||||
{item.recommended_measures_technical && item.recommended_measures_technical.length > 0 && (
|
||||
<div><span className="font-medium text-green-600">Technisch: </span>
|
||||
<span className="text-gray-500">{item.recommended_measures_technical.join(', ')}</span></div>
|
||||
)}
|
||||
{item.recommended_measures_information && item.recommended_measures_information.length > 0 && (
|
||||
<div><span className="font-medium text-yellow-600">Information: </span>
|
||||
<span className="text-gray-500">{item.recommended_measures_information.join(', ')}</span></div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { getRiskColor, getRiskLevelLabel } from './types'
|
||||
import { getRiskColor, getRiskLevelLabel, REVIEW_STATUS_LABELS } from './types'
|
||||
|
||||
export function RiskBadge({ level }: { level: string }) {
|
||||
return (
|
||||
@@ -9,3 +9,18 @@ export function RiskBadge({ level }: { level: string }) {
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function ReviewStatusBadge({ status }: { status: string }) {
|
||||
const colors: Record<string, string> = {
|
||||
draft: 'bg-gray-100 text-gray-600 border-gray-200',
|
||||
in_review: 'bg-blue-100 text-blue-600 border-blue-200',
|
||||
reviewed: 'bg-indigo-100 text-indigo-600 border-indigo-200',
|
||||
approved: 'bg-green-100 text-green-600 border-green-200',
|
||||
rejected: 'bg-red-100 text-red-600 border-red-200',
|
||||
}
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium border ${colors[status] || colors.draft}`}>
|
||||
{REVIEW_STATUS_LABELS[status] || status}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,13 +5,23 @@ export interface Hazard {
|
||||
component_id: string | null
|
||||
component_name: string | null
|
||||
category: string
|
||||
sub_category: string
|
||||
status: string
|
||||
severity: number
|
||||
exposure: number
|
||||
probability: number
|
||||
avoidance: number
|
||||
r_inherent: number
|
||||
risk_level: string
|
||||
machine_module: string
|
||||
lifecycle_phase: string
|
||||
trigger_event: string
|
||||
affected_person: string
|
||||
possible_harm: string
|
||||
hazardous_zone: string
|
||||
review_status: string
|
||||
created_at: string
|
||||
source?: string
|
||||
}
|
||||
|
||||
export interface LibraryHazard {
|
||||
@@ -19,9 +29,31 @@ export interface LibraryHazard {
|
||||
name: string
|
||||
description: string
|
||||
category: string
|
||||
sub_category: string
|
||||
default_severity: number
|
||||
default_exposure: number
|
||||
default_probability: number
|
||||
default_avoidance: number
|
||||
typical_causes: string[]
|
||||
typical_harm: string
|
||||
relevant_lifecycle_phases: string[]
|
||||
recommended_measures_design: string[]
|
||||
recommended_measures_technical: string[]
|
||||
recommended_measures_information: string[]
|
||||
}
|
||||
|
||||
export interface LifecyclePhase {
|
||||
id: string
|
||||
label_de: string
|
||||
label_en: string
|
||||
sort_order: number
|
||||
}
|
||||
|
||||
export interface RoleInfo {
|
||||
id: string
|
||||
label_de: string
|
||||
label_en: string
|
||||
sort_order: number
|
||||
}
|
||||
|
||||
export interface HazardFormData {
|
||||
@@ -32,40 +64,84 @@ export interface HazardFormData {
|
||||
severity: number
|
||||
exposure: number
|
||||
probability: number
|
||||
avoidance: number
|
||||
lifecycle_phase: string
|
||||
trigger_event: string
|
||||
affected_person: string
|
||||
possible_harm: string
|
||||
hazardous_zone: string
|
||||
machine_module: string
|
||||
}
|
||||
|
||||
// Pattern matching types (Phase 5)
|
||||
export interface PatternMatch {
|
||||
pattern_id: string
|
||||
pattern_name: string
|
||||
priority: number
|
||||
matched_tags: string[]
|
||||
}
|
||||
|
||||
export interface HazardSuggestion {
|
||||
category: string
|
||||
source_patterns: string[]
|
||||
confidence: number
|
||||
}
|
||||
|
||||
export interface MeasureSuggestion {
|
||||
measure_id: string
|
||||
source_patterns: string[]
|
||||
}
|
||||
|
||||
export interface EvidenceSuggestion {
|
||||
evidence_id: string
|
||||
source_patterns: string[]
|
||||
}
|
||||
|
||||
export interface MatchOutput {
|
||||
matched_patterns: PatternMatch[]
|
||||
suggested_hazards: HazardSuggestion[]
|
||||
suggested_measures: MeasureSuggestion[]
|
||||
suggested_evidence: EvidenceSuggestion[]
|
||||
resolved_tags: string[]
|
||||
}
|
||||
|
||||
export const HAZARD_CATEGORIES = [
|
||||
'mechanical', 'electrical', 'thermal', 'noise', 'vibration',
|
||||
'radiation', 'material', 'ergonomic', 'software', 'ai_specific',
|
||||
'cybersecurity', 'functional_safety', 'environmental',
|
||||
'mechanical', 'electrical', 'thermal',
|
||||
'pneumatic_hydraulic', 'noise_vibration', 'ergonomic',
|
||||
'material_environmental', 'software_control', 'cyber_network', 'ai_specific',
|
||||
]
|
||||
|
||||
export const CATEGORY_LABELS: Record<string, string> = {
|
||||
mechanical: 'Mechanisch',
|
||||
electrical: 'Elektrisch',
|
||||
thermal: 'Thermisch',
|
||||
noise: 'Laerm',
|
||||
vibration: 'Vibration',
|
||||
radiation: 'Strahlung',
|
||||
material: 'Stoffe/Materialien',
|
||||
ergonomic: 'Ergonomie',
|
||||
software: 'Software',
|
||||
ai_specific: 'KI-spezifisch',
|
||||
cybersecurity: 'Cybersecurity',
|
||||
functional_safety: 'Funktionale Sicherheit',
|
||||
environmental: 'Umgebung',
|
||||
mechanical: 'A. Mechanisch', electrical: 'B. Elektrisch', thermal: 'C. Thermisch',
|
||||
pneumatic_hydraulic: 'D. Pneumatik/Hydraulik', noise_vibration: 'E. Laerm/Vibration',
|
||||
ergonomic: 'F. Ergonomie', material_environmental: 'G. Stoffe/Umwelt',
|
||||
software_control: 'H. Software/Steuerung', cyber_network: 'I. Cyber/Netzwerk',
|
||||
ai_specific: 'J. KI-spezifisch',
|
||||
// Legacy names (backward compat)
|
||||
mechanical_hazard: 'A. Mechanisch', electrical_hazard: 'B. Elektrisch',
|
||||
thermal_hazard: 'C. Thermisch', software_fault: 'H. Software/Steuerung',
|
||||
safety_function_failure: 'H. Sicherheitsfunktionen', false_classification: 'J. KI-spezifisch',
|
||||
unauthorized_access: 'I. Cyber/Netzwerk', configuration_error: 'H. Konfiguration',
|
||||
hmi_error: 'H. HMI-Fehler', integration_error: 'H. Integration',
|
||||
communication_failure: 'I. Kommunikation', sensor_spoofing: 'I. Sensormanipulation',
|
||||
model_drift: 'J. Modelldrift', data_poisoning: 'J. Daten-Poisoning',
|
||||
emc_hazard: 'B. EMV', maintenance_hazard: 'F. Wartung', update_failure: 'H. Update-Fehler',
|
||||
}
|
||||
|
||||
export const STATUS_LABELS: Record<string, string> = {
|
||||
identified: 'Identifiziert',
|
||||
assessed: 'Bewertet',
|
||||
mitigated: 'Gemindert',
|
||||
accepted: 'Akzeptiert',
|
||||
closed: 'Geschlossen',
|
||||
identified: 'Identifiziert', assessed: 'Bewertet',
|
||||
mitigated: 'Gemindert', accepted: 'Akzeptiert', closed: 'Geschlossen',
|
||||
}
|
||||
|
||||
export const REVIEW_STATUS_LABELS: Record<string, string> = {
|
||||
draft: 'Entwurf', in_review: 'In Pruefung',
|
||||
reviewed: 'Geprueft', approved: 'Freigegeben', rejected: 'Abgelehnt',
|
||||
}
|
||||
|
||||
export function getRiskColor(level: string): string {
|
||||
switch (level) {
|
||||
case 'not_acceptable': return 'bg-red-200 text-red-900 border-red-300'
|
||||
case 'very_high': return 'bg-red-100 text-red-700 border-red-200'
|
||||
case 'critical': return 'bg-red-100 text-red-700 border-red-200'
|
||||
case 'high': return 'bg-orange-100 text-orange-700 border-orange-200'
|
||||
case 'medium': return 'bg-yellow-100 text-yellow-700 border-yellow-200'
|
||||
@@ -74,7 +150,15 @@ export function getRiskColor(level: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
export function getRiskLevel(r: number): string {
|
||||
export function getRiskLevelISO(r: number): string {
|
||||
if (r > 300) return 'not_acceptable'
|
||||
if (r >= 151) return 'very_high'
|
||||
if (r >= 61) return 'high'
|
||||
if (r >= 21) return 'medium'
|
||||
return 'low'
|
||||
}
|
||||
|
||||
export function getRiskLevelLegacy(r: number): string {
|
||||
if (r >= 100) return 'critical'
|
||||
if (r >= 50) return 'high'
|
||||
if (r >= 20) return 'medium'
|
||||
@@ -83,6 +167,8 @@ export function getRiskLevel(r: number): string {
|
||||
|
||||
export function getRiskLevelLabel(level: string): string {
|
||||
switch (level) {
|
||||
case 'not_acceptable': return 'Nicht akzeptabel'
|
||||
case 'very_high': return 'Sehr hoch'
|
||||
case 'critical': return 'Kritisch'
|
||||
case 'high': return 'Hoch'
|
||||
case 'medium': return 'Mittel'
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
Hazard, LibraryHazard, LifecyclePhase, RoleInfo, HazardFormData, MatchOutput,
|
||||
CATEGORY_LABELS,
|
||||
} from '../_components/types'
|
||||
|
||||
export function useHazards(projectId: string) {
|
||||
const [hazards, setHazards] = useState<Hazard[]>([])
|
||||
const [library, setLibrary] = useState<LibraryHazard[]>([])
|
||||
const [lifecyclePhases, setLifecyclePhases] = useState<LifecyclePhase[]>([])
|
||||
const [roles, setRoles] = useState<RoleInfo[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [showLibrary, setShowLibrary] = useState(false)
|
||||
const [suggestingAI, setSuggestingAI] = useState(false)
|
||||
const [matchingPatterns, setMatchingPatterns] = useState(false)
|
||||
const [matchResult, setMatchResult] = useState<MatchOutput | null>(null)
|
||||
const [applyingPatterns, setApplyingPatterns] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetchHazards()
|
||||
fetchLifecyclePhases()
|
||||
fetchRoles()
|
||||
}, [projectId]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
async function fetchHazards() {
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/hazards`)
|
||||
if (res.ok) {
|
||||
const json = await res.json()
|
||||
setHazards(json.hazards || json || [])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch hazards:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchLifecyclePhases() {
|
||||
try {
|
||||
const res = await fetch('/api/sdk/v1/iace/lifecycle-phases')
|
||||
if (res.ok) { const json = await res.json(); setLifecyclePhases(json.lifecycle_phases || []) }
|
||||
} catch (err) { console.error('Failed to fetch lifecycle phases:', err) }
|
||||
}
|
||||
|
||||
async function fetchRoles() {
|
||||
try {
|
||||
const res = await fetch('/api/sdk/v1/iace/roles')
|
||||
if (res.ok) { const json = await res.json(); setRoles(json.roles || []) }
|
||||
} catch (err) { console.error('Failed to fetch roles:', err) }
|
||||
}
|
||||
|
||||
async function fetchLibrary() {
|
||||
try {
|
||||
const res = await fetch('/api/sdk/v1/iace/hazard-library')
|
||||
if (res.ok) { const json = await res.json(); setLibrary(json.hazard_library || json.hazards || json || []) }
|
||||
} catch (err) { console.error('Failed to fetch hazard library:', err) }
|
||||
setShowLibrary(true)
|
||||
}
|
||||
|
||||
async function handleAddFromLibrary(item: LibraryHazard) {
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/hazards`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: item.name, description: item.description, category: item.category,
|
||||
sub_category: item.sub_category || '', severity: item.default_severity,
|
||||
exposure: item.default_exposure || 3, probability: item.default_probability,
|
||||
avoidance: item.default_avoidance || 3,
|
||||
}),
|
||||
})
|
||||
if (res.ok) await fetchHazards()
|
||||
} catch (err) { console.error('Failed to add from library:', err) }
|
||||
}
|
||||
|
||||
async function handleSubmit(data: HazardFormData) {
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/hazards`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
if (res.ok) { setShowForm(false); await fetchHazards() }
|
||||
} catch (err) { console.error('Failed to add hazard:', err) }
|
||||
}
|
||||
|
||||
async function handleAISuggestions() {
|
||||
setSuggestingAI(true)
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/hazards/suggest`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
if (res.ok) await fetchHazards()
|
||||
} catch (err) { console.error('Failed to get AI suggestions:', err) }
|
||||
finally { setSuggestingAI(false) }
|
||||
}
|
||||
|
||||
async function handlePatternMatching() {
|
||||
setMatchingPatterns(true)
|
||||
setMatchResult(null)
|
||||
try {
|
||||
const compRes = await fetch(`/api/sdk/v1/iace/projects/${projectId}/components`)
|
||||
let componentLibraryIds: string[] = []
|
||||
let energySourceIds: string[] = []
|
||||
if (compRes.ok) {
|
||||
const compJson = await compRes.json()
|
||||
const comps = compJson.components || compJson || []
|
||||
componentLibraryIds = comps.map((c: { library_component_id?: string }) => c.library_component_id).filter(Boolean) as string[]
|
||||
const allEnergyIds = comps.flatMap((c: { energy_source_ids?: string[] }) => c.energy_source_ids || [])
|
||||
energySourceIds = [...new Set(allEnergyIds)] as string[]
|
||||
}
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/match-patterns`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ component_library_ids: componentLibraryIds, energy_source_ids: energySourceIds, lifecycle_phases: [], custom_tags: [] }),
|
||||
})
|
||||
if (res.ok) setMatchResult(await res.json())
|
||||
} catch (err) { console.error('Failed to match patterns:', err) }
|
||||
finally { setMatchingPatterns(false) }
|
||||
}
|
||||
|
||||
async function handleApplyPatterns(
|
||||
acceptedHazardCats: string[], acceptedMeasureIds: string[],
|
||||
acceptedEvidenceIds: string[], patternIds: string[],
|
||||
) {
|
||||
setApplyingPatterns(true)
|
||||
try {
|
||||
const acceptedHazards = acceptedHazardCats.map(cat => ({
|
||||
name: `Auto: ${CATEGORY_LABELS[cat] || cat}`,
|
||||
description: `Automatisch erkannte Gefaehrdung aus Pattern-Matching (Kategorie: ${cat})`,
|
||||
category: cat, severity: 3, exposure: 3, probability: 3, avoidance: 3,
|
||||
}))
|
||||
const acceptedMeasures = acceptedMeasureIds.map(id => ({
|
||||
name: `Auto: Massnahme ${id}`,
|
||||
description: `Automatisch vorgeschlagene Massnahme aus Pattern-Matching`,
|
||||
reduction_type: 'design',
|
||||
}))
|
||||
const acceptedEvidence = acceptedEvidenceIds.map(id => ({
|
||||
title: `Auto: Nachweis ${id}`,
|
||||
description: `Automatisch vorgeschlagener Nachweis aus Pattern-Matching`,
|
||||
method: 'test_report',
|
||||
}))
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/apply-patterns`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ accepted_hazards: acceptedHazards, accepted_measures: acceptedMeasures, accepted_evidence: acceptedEvidence, source_pattern_ids: patternIds }),
|
||||
})
|
||||
if (res.ok) { setMatchResult(null); await fetchHazards() }
|
||||
} catch (err) { console.error('Failed to apply patterns:', err) }
|
||||
finally { setApplyingPatterns(false) }
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
if (!confirm('Gefaehrdung wirklich loeschen?')) return
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/hazards/${id}`, { method: 'DELETE' })
|
||||
if (res.ok) await fetchHazards()
|
||||
} catch (err) { console.error('Failed to delete hazard:', err) }
|
||||
}
|
||||
|
||||
return {
|
||||
hazards, library, lifecyclePhases, roles, loading,
|
||||
showForm, setShowForm, showLibrary, setShowLibrary,
|
||||
suggestingAI, matchingPatterns, matchResult, setMatchResult, applyingPatterns,
|
||||
fetchLibrary, handleAddFromLibrary, handleSubmit,
|
||||
handleAISuggestions, handlePatternMatching, handleApplyPatterns, handleDelete,
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user