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 { MitigationForm } from './_components/MitigationForm'
|
||||||
import { StatusBadge } from './_components/StatusBadge'
|
import { StatusBadge } from './_components/StatusBadge'
|
||||||
import { MitigationHints } from './_components/MitigationHints'
|
import { MitigationHints } from './_components/MitigationHints'
|
||||||
|
import { OshaDistanceNote } from './_components/OshaDistanceNote'
|
||||||
|
import { useMinimumDistances } from './_hooks/useMinimumDistances'
|
||||||
import { ProtectiveMeasure } from './_components/types'
|
import { ProtectiveMeasure } from './_components/types'
|
||||||
import { useMitigations } from './_hooks/useMitigations'
|
import { useMitigations } from './_hooks/useMitigations'
|
||||||
|
|
||||||
@@ -24,6 +26,7 @@ export default function MitigationsPage() {
|
|||||||
} = useMitigations(projectId)
|
} = useMitigations(projectId)
|
||||||
|
|
||||||
const [measureNorms, setMeasureNorms] = useState<Record<string, string[]>>({})
|
const [measureNorms, setMeasureNorms] = useState<Record<string, string[]>>({})
|
||||||
|
const { byName: oshaByName } = useMinimumDistances()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('/api/sdk/v1/iace/protective-measures-library')
|
fetch('/api/sdk/v1/iace/protective-measures-library')
|
||||||
@@ -276,6 +279,9 @@ export default function MitigationsPage() {
|
|||||||
{refs?.length > 0 && (
|
{refs?.length > 0 && (
|
||||||
<p className="px-12 pb-2 text-[11px] text-blue-500">Normen: {refs.join(', ')}</p>
|
<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) => {
|
{instances.map((m) => {
|
||||||
const isDetailOpen = expandedMeasure === m.id
|
const isDetailOpen = expandedMeasure === m.id
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -10,15 +10,22 @@ import (
|
|||||||
// ListMinimumDistances handles GET /minimum-distances.
|
// ListMinimumDistances handles GET /minimum-distances.
|
||||||
// Read-only OSHA safety-distance reference (29 CFR 1910, US public domain)
|
// Read-only OSHA safety-distance reference (29 CFR 1910, US public domain)
|
||||||
// plus the curated measure→distance link table, so an auditor can see WHERE a
|
// plus the curated measure→distance link table, so an auditor can see WHERE a
|
||||||
// measure's mm figure comes from. Optional ?measure_id= returns only the
|
// measure's mm figure comes from. Scope to one measure via ?measure_id= or
|
||||||
// distances (and links) tied to that protective measure.
|
// ?measure_name= (the latter lets a persisted mitigation — which stores the
|
||||||
|
// name, not the catalog ID — resolve its anchor without a schema change).
|
||||||
func (h *IACEHandler) ListMinimumDistances(c *gin.Context) {
|
func (h *IACEHandler) ListMinimumDistances(c *gin.Context) {
|
||||||
if mid := c.Query("measure_id"); mid != "" {
|
mid := c.Query("measure_id")
|
||||||
|
mname := c.Query("measure_name")
|
||||||
|
if mid == "" && mname != "" {
|
||||||
|
mid, _ = iace.MeasureIDByName(mname) // "" if unknown → empty scoped result
|
||||||
|
}
|
||||||
|
if mid != "" || mname != "" {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"measure_id": mid,
|
"measure_id": mid,
|
||||||
"distances": iace.MinimumDistancesForMeasure(mid),
|
"measure_name": mname,
|
||||||
"links": iace.LinksForMeasure(mid),
|
"distances": iace.MinimumDistancesForMeasure(mid),
|
||||||
"note": iace.MinimumDistanceNote,
|
"links": iace.LinksForMeasure(mid),
|
||||||
|
"note": iace.MinimumDistanceNote,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -200,6 +200,7 @@ func GetMinimumDistanceByID(id string) (MinimumDistance, bool) {
|
|||||||
// OSHA entry is offered as the public-domain pendant for independent check.
|
// OSHA entry is offered as the public-domain pendant for independent check.
|
||||||
type MeasureDistanceLink struct {
|
type MeasureDistanceLink struct {
|
||||||
MeasureID string `json:"measure_id"`
|
MeasureID string `json:"measure_id"`
|
||||||
|
MeasureName string `json:"measure_name,omitempty"` // resolved from the library for name-based UI matching
|
||||||
DistanceIDs []string `json:"distance_ids"`
|
DistanceIDs []string `json:"distance_ids"`
|
||||||
Relation string `json:"relation"`
|
Relation string `json:"relation"`
|
||||||
Note string `json:"note,omitempty"`
|
Note string `json:"note,omitempty"`
|
||||||
@@ -216,7 +217,7 @@ const (
|
|||||||
// ISO value (e.g. M340 robot teach speed, M368 air-receiver wall) are NOT
|
// ISO value (e.g. M340 robot teach speed, M368 air-receiver wall) are NOT
|
||||||
// linked — that would imply a public-domain anchor that does not exist.
|
// linked — that would imply a public-domain anchor that does not exist.
|
||||||
func AllMeasureDistanceLinks() []MeasureDistanceLink {
|
func AllMeasureDistanceLinks() []MeasureDistanceLink {
|
||||||
return []MeasureDistanceLink{
|
links := []MeasureDistanceLink{
|
||||||
{
|
{
|
||||||
MeasureID: "M600",
|
MeasureID: "M600",
|
||||||
DistanceIDs: []string{"MD_OSHA_217_PSDI"},
|
DistanceIDs: []string{"MD_OSHA_217_PSDI"},
|
||||||
@@ -236,6 +237,18 @@ func AllMeasureDistanceLinks() []MeasureDistanceLink {
|
|||||||
Note: "OSHA §1910.212(a)(5) Luefterschutz (max. 12 mm Spaltoeffnung) als Public-Domain-Pendant zu ISO 13857.",
|
Note: "OSHA §1910.212(a)(5) Luefterschutz (max. 12 mm Spaltoeffnung) als Public-Domain-Pendant zu ISO 13857.",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
// Enrich each link with the measure name so a name-based UI (persisted
|
||||||
|
// mitigations carry the name, not the ID) can match without a second lookup.
|
||||||
|
lib := GetProtectiveMeasureLibrary()
|
||||||
|
for i := range links {
|
||||||
|
for _, m := range lib {
|
||||||
|
if m.ID == links[i].MeasureID {
|
||||||
|
links[i].MeasureName = m.Name
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return links
|
||||||
}
|
}
|
||||||
|
|
||||||
// LinksForMeasure returns the distance links declared for one measure.
|
// LinksForMeasure returns the distance links declared for one measure.
|
||||||
@@ -263,3 +276,33 @@ func MinimumDistancesForMeasure(measureID string) []MinimumDistance {
|
|||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MeasureIDByName resolves a protective-measure name to its catalog ID. Measure
|
||||||
|
// names are unique across the library (verified by test), so a PERSISTED
|
||||||
|
// mitigation — which stores the measure name verbatim but NOT the catalog ID —
|
||||||
|
// can still be joined to its OSHA distance link without a DB schema change or a
|
||||||
|
// re-seed. This is the bridge that lets a project's measures surface the anchor.
|
||||||
|
func MeasureIDByName(name string) (string, bool) {
|
||||||
|
for _, m := range GetProtectiveMeasureLibrary() {
|
||||||
|
if m.Name == name {
|
||||||
|
return m.ID, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// MinimumDistancesForMeasureName resolves OSHA distances by measure NAME.
|
||||||
|
func MinimumDistancesForMeasureName(name string) []MinimumDistance {
|
||||||
|
if id, ok := MeasureIDByName(name); ok {
|
||||||
|
return MinimumDistancesForMeasure(id)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LinksForMeasureName resolves distance links by measure NAME.
|
||||||
|
func LinksForMeasureName(name string) []MeasureDistanceLink {
|
||||||
|
if id, ok := MeasureIDByName(name); ok {
|
||||||
|
return LinksForMeasure(id)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -113,6 +113,38 @@ func TestMeasureDistanceLinks_ValueSourceProseConsistency(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The name→ID resolver underpins surfacing OSHA anchors on persisted
|
||||||
|
// mitigations (which store the name, not the catalog ID). It is only safe if
|
||||||
|
// measure names are unique — pin that, and that the resolver round-trips for
|
||||||
|
// every linked measure.
|
||||||
|
func TestMeasureNames_UniqueAndResolvable(t *testing.T) {
|
||||||
|
idByName := map[string]string{}
|
||||||
|
nameByID := map[string]string{}
|
||||||
|
for _, m := range GetProtectiveMeasureLibrary() {
|
||||||
|
if prev, dup := idByName[m.Name]; dup {
|
||||||
|
t.Errorf("duplicate measure name %q (IDs %s, %s) — name→ID resolver unsafe",
|
||||||
|
m.Name, prev, m.ID)
|
||||||
|
}
|
||||||
|
idByName[m.Name] = m.ID
|
||||||
|
nameByID[m.ID] = m.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, l := range AllMeasureDistanceLinks() {
|
||||||
|
name := nameByID[l.MeasureID]
|
||||||
|
if name == "" {
|
||||||
|
t.Errorf("linked measure %q not found in library", l.MeasureID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
gotID, ok := MeasureIDByName(name)
|
||||||
|
if !ok || gotID != l.MeasureID {
|
||||||
|
t.Errorf("MeasureIDByName(%q) = %q,%v; want %q", name, gotID, ok, l.MeasureID)
|
||||||
|
}
|
||||||
|
if len(MinimumDistancesForMeasureName(name)) != len(MinimumDistancesForMeasure(l.MeasureID)) {
|
||||||
|
t.Errorf("name vs id resolution mismatch for %q", l.MeasureID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// proseMentionsDistance reports whether the (dot-stripped, lowercased) measure
|
// proseMentionsDistance reports whether the (dot-stripped, lowercased) measure
|
||||||
// text contains a numeric form of the distance's value, formula or recommended mm.
|
// text contains a numeric form of the distance's value, formula or recommended mm.
|
||||||
func proseMentionsDistance(text string, md MinimumDistance) bool {
|
func proseMentionsDistance(text string, md MinimumDistance) bool {
|
||||||
|
|||||||
Reference in New Issue
Block a user