feat(iace): surface OSHA distance anchor in Maßnahmen tab (name-resolved)
Makes the OSHA minimum-distance anchor visible per measure in a project without a DB schema change or re-seed: persisted mitigations store the measure NAME verbatim (not the catalog ID), and measure names are unique across the 578-entry library (pinned by test), so a name→ID resolver bridges the gap. Backend: MeasureIDByName + MinimumDistancesForMeasureName/LinksForMeasureName; /iace/minimum-distances now accepts ?measure_name=; link table enriched with measure_name for one-request UI matching. Frontend: useMinimumDistances loads the link table once and keys it by name; OshaDistanceNote renders the anchor (value/CFR/license/EU-hint/relation) on the matching measure group in the Maßnahmen tab. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+53
@@ -0,0 +1,53 @@
|
||||
'use client'
|
||||
|
||||
import { OshaAnchor, MinimumDistance } from '../_hooks/useMinimumDistances'
|
||||
|
||||
const RELATION_LABEL: Record<string, string> = {
|
||||
value_source: 'Wertquelle',
|
||||
public_domain_crossref: 'Public-Domain-Pendant',
|
||||
}
|
||||
|
||||
function distanceLine(d: MinimumDistance): string {
|
||||
if (d.formula_description) return d.formula_description
|
||||
if (d.recommended_min_mm && d.recommended_max_mm)
|
||||
return `${d.recommended_min_mm}–${d.recommended_max_mm} mm (empfohlen, sicherheitsseitig gerundet)`
|
||||
if (d.recommended_mm) return `${d.recommended_mm} mm (empfohlen, sicherheitsseitig gerundet)`
|
||||
return d.context
|
||||
}
|
||||
|
||||
/** Renders the OSHA safety-distance anchor for one measure (audit view). */
|
||||
export function OshaDistanceNote({ entry }: { entry: OshaAnchor }) {
|
||||
const { link, distances } = entry
|
||||
if (!distances.length) return null
|
||||
|
||||
return (
|
||||
<div className="px-12 pb-2">
|
||||
<div className="rounded border border-amber-200 dark:border-amber-800 bg-amber-50/50 dark:bg-amber-900/15 p-2">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wide text-amber-700 dark:text-amber-300">
|
||||
OSHA-Sicherheitsabstand
|
||||
</span>
|
||||
<span className="text-[10px] text-amber-600/80 dark:text-amber-400/80">
|
||||
{RELATION_LABEL[link.relation] || link.relation}
|
||||
</span>
|
||||
</div>
|
||||
{distances.map((d) => (
|
||||
<div key={d.id} className="text-[11px] text-gray-600 dark:text-gray-300 mb-1">
|
||||
<span className="font-medium">{distanceLine(d)}</span>
|
||||
<span className="text-gray-400">
|
||||
{' · '}
|
||||
{d.source_cfr} · {d.license}
|
||||
</span>
|
||||
{(d.eu_norm_hints || []).map((h, i) => (
|
||||
<span key={i} className="block text-[10px] text-gray-400 mt-0.5">
|
||||
EU: {h.norm}
|
||||
{h.din_comparison_note ? ` — ${h.din_comparison_note}` : ''}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
{link.note && <p className="text-[10px] text-gray-400 italic mt-0.5">{link.note}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
export interface EuNormHint {
|
||||
norm: string
|
||||
section?: string
|
||||
din_comparison_note?: string
|
||||
}
|
||||
|
||||
export interface MinimumDistance {
|
||||
id: string
|
||||
source_cfr?: string
|
||||
source_table?: string
|
||||
license: string
|
||||
context: string
|
||||
body_part?: string
|
||||
recommended_mm?: number
|
||||
recommended_min_mm?: number
|
||||
recommended_max_mm?: number
|
||||
formula_description?: string
|
||||
formula_mm_per_second?: number
|
||||
rounding_note?: string
|
||||
eu_norm_hints?: EuNormHint[]
|
||||
}
|
||||
|
||||
export interface MeasureDistanceLink {
|
||||
measure_id: string
|
||||
measure_name?: string
|
||||
distance_ids: string[]
|
||||
relation: string // "value_source" | "public_domain_crossref"
|
||||
note?: string
|
||||
}
|
||||
|
||||
export interface OshaAnchor {
|
||||
link: MeasureDistanceLink
|
||||
distances: MinimumDistance[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the OSHA minimum-distance link table ONCE and returns a lookup keyed by
|
||||
* (lower-cased) measure name. A persisted mitigation stores the measure name
|
||||
* verbatim, so the Maßnahmen tab can surface the OSHA anchor by matching on name
|
||||
* — no per-row request, no catalog ID needed.
|
||||
*/
|
||||
export function useMinimumDistances() {
|
||||
const [byName, setByName] = useState<Record<string, OshaAnchor>>({})
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
async function load() {
|
||||
try {
|
||||
const res = await fetch('/api/sdk/v1/iace/minimum-distances')
|
||||
if (!res.ok) return
|
||||
const json = (await res.json()) as { distances: MinimumDistance[]; links: MeasureDistanceLink[] }
|
||||
const byId = Object.fromEntries((json.distances || []).map((d) => [d.id, d]))
|
||||
const map: Record<string, OshaAnchor> = {}
|
||||
for (const link of json.links || []) {
|
||||
if (!link.measure_name) continue
|
||||
map[link.measure_name.toLowerCase()] = {
|
||||
link,
|
||||
distances: link.distance_ids.map((id) => byId[id]).filter(Boolean),
|
||||
}
|
||||
}
|
||||
if (!cancelled) setByName(map)
|
||||
} catch (err) {
|
||||
console.error('Failed to load minimum distances:', err)
|
||||
}
|
||||
}
|
||||
load()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { byName }
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import { SuggestMeasuresModal } from './_components/SuggestMeasuresModal'
|
||||
import { MitigationForm } from './_components/MitigationForm'
|
||||
import { StatusBadge } from './_components/StatusBadge'
|
||||
import { MitigationHints } from './_components/MitigationHints'
|
||||
import { OshaDistanceNote } from './_components/OshaDistanceNote'
|
||||
import { useMinimumDistances } from './_hooks/useMinimumDistances'
|
||||
import { ProtectiveMeasure } from './_components/types'
|
||||
import { useMitigations } from './_hooks/useMitigations'
|
||||
|
||||
@@ -24,6 +26,7 @@ export default function MitigationsPage() {
|
||||
} = useMitigations(projectId)
|
||||
|
||||
const [measureNorms, setMeasureNorms] = useState<Record<string, string[]>>({})
|
||||
const { byName: oshaByName } = useMinimumDistances()
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/sdk/v1/iace/protective-measures-library')
|
||||
@@ -276,6 +279,9 @@ export default function MitigationsPage() {
|
||||
{refs?.length > 0 && (
|
||||
<p className="px-12 pb-2 text-[11px] text-blue-500">Normen: {refs.join(', ')}</p>
|
||||
)}
|
||||
{oshaByName[title.toLowerCase()] && (
|
||||
<OshaDistanceNote entry={oshaByName[title.toLowerCase()]} />
|
||||
)}
|
||||
{instances.map((m) => {
|
||||
const isDetailOpen = expandedMeasure === m.id
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user