feat(iace-ui): component presence/CE review + machine-type dropdown
- Components view: three presence sections (Vorhanden / Nicht vorhanden / Geloescht) with bidirectional move + soft-delete (audit-visible, restorable), so the expert corrects the engine's best-effort negation in both directions. - CE marking per component (bought robot/actuator/SPS) with a clear "validate the integrated safety function (PL/SIL)" note when also safety-relevant. Safe semantics: hazards are not suppressed, only provenance is surfaced. - Project-create form: machine type is now a grouped dropdown from the engine's controlled vocabulary (GET /machine-types) instead of free text. - Knowledge graph: component→hazard edges use the real component_id. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+38
-1
@@ -10,13 +10,19 @@ export function ComponentTreeNode({
|
|||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
onAddChild,
|
onAddChild,
|
||||||
|
onMarkAbsent,
|
||||||
|
onToggleCE,
|
||||||
}: {
|
}: {
|
||||||
component: Component
|
component: Component
|
||||||
depth: number
|
depth: number
|
||||||
onEdit: (c: Component) => void
|
onEdit: (c: Component) => void
|
||||||
onDelete: (id: string) => void
|
onDelete: (id: string) => void
|
||||||
onAddChild: (parentId: string) => void
|
onAddChild: (parentId: string) => void
|
||||||
|
onMarkAbsent?: (id: string) => void
|
||||||
|
onToggleCE?: (id: string, value: boolean) => void
|
||||||
}) {
|
}) {
|
||||||
|
const ceMarked = !!component.ce_marked
|
||||||
|
const safetyRelevant = !!(component.is_safety_relevant ?? component.safety_relevant)
|
||||||
const [expanded, setExpanded] = useState(true)
|
const [expanded, setExpanded] = useState(true)
|
||||||
const hasChildren = component.children && component.children.length > 0
|
const hasChildren = component.children && component.children.length > 0
|
||||||
|
|
||||||
@@ -53,6 +59,18 @@ export function ComponentTreeNode({
|
|||||||
Bibliothek
|
Bibliothek
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{ceMarked && (
|
||||||
|
<span className="ml-2 inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-700"
|
||||||
|
title="Zugekauft mit eigener CE / Konformitaetserklaerung — Nachweis via Hersteller-DoC">
|
||||||
|
CE
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{ceMarked && safetyRelevant && (
|
||||||
|
<span className="ml-2 inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-amber-100 text-amber-800"
|
||||||
|
title="Die CE der Box deckt die integrierte Sicherheitsfunktion NICHT — PL/SIL nach EN ISO 13849-1 / IEC 62061 muss validiert werden">
|
||||||
|
⚠ Sicherheitsfunktion validieren (PL/SIL)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{component.description && (
|
{component.description && (
|
||||||
@@ -74,7 +92,24 @@ export function ComponentTreeNode({
|
|||||||
<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" />
|
<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>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => onDelete(component.id)} title="Loeschen"
|
{onToggleCE && (
|
||||||
|
<button onClick={() => onToggleCE(component.id, !ceMarked)}
|
||||||
|
title={ceMarked ? 'CE-Markierung entfernen' : 'Als zugekaufte CE-Komponente markieren (Roboter/Aktor/SPS)'}
|
||||||
|
className={`p-1 rounded transition-colors ${ceMarked ? 'text-blue-600 bg-blue-50' : 'text-gray-400 hover:text-blue-600 hover:bg-blue-50'}`}>
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onMarkAbsent && (
|
||||||
|
<button onClick={() => onMarkAbsent(component.id)} title="Als nicht vorhanden markieren"
|
||||||
|
className="p-1 text-gray-400 hover:text-amber-600 hover:bg-amber-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="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button onClick={() => onDelete(component.id)} title="Loeschen (in Geloescht verschieben)"
|
||||||
className="p-1 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded transition-colors">
|
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">
|
<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" />
|
<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" />
|
||||||
@@ -93,6 +128,8 @@ export function ComponentTreeNode({
|
|||||||
onEdit={onEdit}
|
onEdit={onEdit}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
onAddChild={onAddChild}
|
onAddChild={onAddChild}
|
||||||
|
onMarkAbsent={onMarkAbsent}
|
||||||
|
onToggleCE={onToggleCE}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Component } from './types'
|
||||||
|
|
||||||
|
interface Action {
|
||||||
|
label: string
|
||||||
|
onClick: (id: string) => void
|
||||||
|
variant: 'primary' | 'danger' | 'neutral'
|
||||||
|
}
|
||||||
|
|
||||||
|
const VARIANT: Record<string, string> = {
|
||||||
|
primary: 'text-green-700 border-green-300 hover:bg-green-50',
|
||||||
|
danger: 'text-red-700 border-red-300 hover:bg-red-50',
|
||||||
|
neutral: 'text-gray-600 border-gray-300 hover:bg-gray-50',
|
||||||
|
}
|
||||||
|
|
||||||
|
// PresenceSection renders a flat list of components in one presence state
|
||||||
|
// (nicht_vorhanden / geloescht) with the moves the expert can apply to each.
|
||||||
|
export function PresenceSection({
|
||||||
|
title, hint, accent, components, actions,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
hint: string
|
||||||
|
accent: string
|
||||||
|
components: Component[]
|
||||||
|
actions: Action[]
|
||||||
|
}) {
|
||||||
|
if (components.length === 0) return null
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
|
<div className={`px-4 py-3 border-l-4 ${accent} bg-gray-50 dark:bg-gray-750`}>
|
||||||
|
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
|
{title} <span className="text-gray-400">({components.length})</span>
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">{hint}</p>
|
||||||
|
</div>
|
||||||
|
<ul className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||||
|
{components.map((c) => (
|
||||||
|
<li key={c.id} className="flex items-center justify-between gap-3 px-4 py-2.5">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<span className="text-sm font-medium text-gray-900 dark:text-white">{c.name}</span>
|
||||||
|
{c.type && <span className="ml-2 text-xs text-gray-400">{c.type}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
{actions.map((a) => (
|
||||||
|
<button key={a.label} onClick={() => a.onClick(c.id)}
|
||||||
|
className={`px-2.5 py-1 text-xs font-medium border rounded-md transition-colors ${VARIANT[a.variant]}`}>
|
||||||
|
{a.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
export type PresenceStatus = 'vorhanden' | 'nicht_vorhanden' | 'geloescht'
|
||||||
|
|
||||||
export interface Component {
|
export interface Component {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
@@ -5,7 +7,16 @@ export interface Component {
|
|||||||
version: string
|
version: string
|
||||||
description: string
|
description: string
|
||||||
safety_relevant: boolean
|
safety_relevant: boolean
|
||||||
|
// is_safety_relevant is the backend's field name (the form's `safety_relevant`
|
||||||
|
// does not currently round-trip). Read this when deriving CE obligations.
|
||||||
|
is_safety_relevant?: boolean
|
||||||
|
// ce_marked: bought component carrying its own CE / DoC (robot, actuator, SPS).
|
||||||
|
// Safe semantics — no hazard suppression; drives provenance + the PL/SIL
|
||||||
|
// validation note when also safety-relevant.
|
||||||
ce_marked?: boolean
|
ce_marked?: boolean
|
||||||
|
// presence_status: 'vorhanden' (default) | 'nicht_vorhanden' (engine negation
|
||||||
|
// verdict, awaiting expert review) | 'geloescht' (soft-deleted, restorable).
|
||||||
|
presence_status?: PresenceStatus
|
||||||
parent_id: string | null
|
parent_id: string | null
|
||||||
children: Component[]
|
children: Component[]
|
||||||
library_component_id?: string
|
library_component_id?: string
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Component, ComponentFormData, LibraryComponent, EnergySource, buildTree } from '../_components/types'
|
import { Component, ComponentFormData, LibraryComponent, EnergySource, PresenceStatus, buildTree } from '../_components/types'
|
||||||
|
|
||||||
export function useComponents(projectId: string) {
|
export function useComponents(projectId: string) {
|
||||||
const [components, setComponents] = useState<Component[]>([])
|
const [components, setComponents] = useState<Component[]>([])
|
||||||
@@ -47,16 +47,43 @@ export function useComponents(projectId: string) {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete(id: string) {
|
// Move a component between presence states (vorhanden / nicht_vorhanden /
|
||||||
if (!confirm('Komponente wirklich loeschen? Unterkomponenten werden ebenfalls entfernt.')) return
|
// geloescht). Used for the expert's bidirectional review of auto-detected
|
||||||
|
// components.
|
||||||
|
async function setPresence(id: string, status: PresenceStatus) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/components/${id}`, { method: 'DELETE' })
|
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/components/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ presence_status: status }),
|
||||||
|
})
|
||||||
if (res.ok) await fetchComponents()
|
if (res.ok) await fetchComponents()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to delete component:', err)
|
console.error('Failed to set presence:', err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mark a component as a bought CE product (robot, actuator, SPS ...). Safe
|
||||||
|
// semantics: no hazard suppression — only provenance + PL/SIL note.
|
||||||
|
async function setCEMarked(id: string, value: boolean) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/components/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ ce_marked: value }),
|
||||||
|
})
|
||||||
|
if (res.ok) await fetchComponents()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to set ce_marked:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft-delete: the component moves to the "Geloescht" list (restorable); it is
|
||||||
|
// not removed from the project, so nothing silently disappears.
|
||||||
|
async function handleDelete(id: string) {
|
||||||
|
await setPresence(id, 'geloescht')
|
||||||
|
}
|
||||||
|
|
||||||
async function handleAddFromLibrary(libraryComps: LibraryComponent[], energySrcs: EnergySource[]) {
|
async function handleAddFromLibrary(libraryComps: LibraryComponent[], energySrcs: EnergySource[]) {
|
||||||
const energySourceIds = energySrcs.map(e => e.id)
|
const energySourceIds = energySrcs.map(e => e.id)
|
||||||
for (const comp of libraryComps) {
|
for (const comp of libraryComps) {
|
||||||
@@ -88,5 +115,7 @@ export function useComponents(projectId: string) {
|
|||||||
handleSubmit,
|
handleSubmit,
|
||||||
handleDelete,
|
handleDelete,
|
||||||
handleAddFromLibrary,
|
handleAddFromLibrary,
|
||||||
|
setPresence,
|
||||||
|
setCEMarked,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,17 +2,25 @@
|
|||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useParams } from 'next/navigation'
|
import { useParams } from 'next/navigation'
|
||||||
import { Component } from './_components/types'
|
import { Component, buildTree } from './_components/types'
|
||||||
import { ComponentTreeNode } from './_components/ComponentTreeNode'
|
import { ComponentTreeNode } from './_components/ComponentTreeNode'
|
||||||
import { ComponentForm } from './_components/ComponentForm'
|
import { ComponentForm } from './_components/ComponentForm'
|
||||||
import { ComponentLibraryModal } from './_components/ComponentLibraryModal'
|
import { ComponentLibraryModal } from './_components/ComponentLibraryModal'
|
||||||
|
import { PresenceSection } from './_components/PresenceSection'
|
||||||
import { useComponents } from './_hooks/useComponents'
|
import { useComponents } from './_hooks/useComponents'
|
||||||
|
|
||||||
export default function ComponentsPage() {
|
export default function ComponentsPage() {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const projectId = params.projectId as string
|
const projectId = params.projectId as string
|
||||||
|
|
||||||
const { loading, tree, handleSubmit, handleDelete, handleAddFromLibrary } = useComponents(projectId)
|
const { loading, components, handleSubmit, handleDelete, handleAddFromLibrary, setPresence, setCEMarked } = useComponents(projectId)
|
||||||
|
|
||||||
|
// Split auto-detected components by presence so the expert can review the
|
||||||
|
// engine's best-effort negation verdicts and move items in both directions.
|
||||||
|
const present = components.filter((c) => !c.presence_status || c.presence_status === 'vorhanden')
|
||||||
|
const negated = components.filter((c) => c.presence_status === 'nicht_vorhanden')
|
||||||
|
const deleted = components.filter((c) => c.presence_status === 'geloescht')
|
||||||
|
const tree = buildTree(present)
|
||||||
|
|
||||||
const [showForm, setShowForm] = useState(false)
|
const [showForm, setShowForm] = useState(false)
|
||||||
const [editingComponent, setEditingComponent] = useState<Component | null>(null)
|
const [editingComponent, setEditingComponent] = useState<Component | null>(null)
|
||||||
@@ -110,7 +118,9 @@ export default function ComponentsPage() {
|
|||||||
<div className="py-1">
|
<div className="py-1">
|
||||||
{tree.map((component) => (
|
{tree.map((component) => (
|
||||||
<ComponentTreeNode key={component.id} component={component} depth={0}
|
<ComponentTreeNode key={component.id} component={component} depth={0}
|
||||||
onEdit={handleEdit} onDelete={handleDelete} onAddChild={handleAddChild} />
|
onEdit={handleEdit} onDelete={handleDelete} onAddChild={handleAddChild}
|
||||||
|
onMarkAbsent={(id) => setPresence(id, 'nicht_vorhanden')}
|
||||||
|
onToggleCE={setCEMarked} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -140,6 +150,27 @@ export default function ComponentsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<PresenceSection
|
||||||
|
title="Nicht vorhanden"
|
||||||
|
hint={'Vom System als verneint erkannt (z. B. „keine Pneumatik"). Pruefen Sie und verschieben Sie bei Bedarf zu Vorhanden.'}
|
||||||
|
accent="border-amber-400"
|
||||||
|
components={negated}
|
||||||
|
actions={[
|
||||||
|
{ label: '→ Vorhanden', variant: 'primary', onClick: (id) => setPresence(id, 'vorhanden') },
|
||||||
|
{ label: 'Loeschen', variant: 'danger', onClick: handleDelete },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PresenceSection
|
||||||
|
title="Geloescht"
|
||||||
|
hint="Entfernte Komponenten bleiben zur Nachvollziehbarkeit sichtbar und koennen wiederhergestellt werden."
|
||||||
|
accent="border-gray-400"
|
||||||
|
components={deleted}
|
||||||
|
actions={[
|
||||||
|
{ label: 'Wiederherstellen', variant: 'neutral', onClick: (id) => setPresence(id, 'vorhanden') },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-4
@@ -3,7 +3,7 @@
|
|||||||
import { useState, useEffect, useMemo } from 'react'
|
import { useState, useEffect, useMemo } from 'react'
|
||||||
|
|
||||||
interface Component { id: string; name: string; component_type: string }
|
interface Component { id: string; name: string; component_type: string }
|
||||||
interface Hazard { id: string; name: string; category: string; operational_states?: string[] }
|
interface Hazard { id: string; name: string; category: string; operational_states?: string[]; component_id?: string }
|
||||||
interface Mitigation { id: string; name?: string; title?: string; reduction_type: string; hazard_id?: string; linked_hazard_ids?: string[] }
|
interface Mitigation { id: string; name?: string; title?: string; reduction_type: string; hazard_id?: string; linked_hazard_ids?: string[] }
|
||||||
|
|
||||||
export interface GraphNode {
|
export interface GraphNode {
|
||||||
@@ -56,6 +56,7 @@ export function useKnowledgeGraph(projectId: string) {
|
|||||||
setHazards((j.hazards || j || []).map((h: Record<string, unknown>) => ({
|
setHazards((j.hazards || j || []).map((h: Record<string, unknown>) => ({
|
||||||
id: h.id as string, name: h.name as string, category: h.category as string || '',
|
id: h.id as string, name: h.name as string, category: h.category as string || '',
|
||||||
operational_states: (h.operational_states || []) as string[],
|
operational_states: (h.operational_states || []) as string[],
|
||||||
|
component_id: (h.component_id || '') as string,
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
if (mitRes.ok) {
|
if (mitRes.ok) {
|
||||||
@@ -89,17 +90,20 @@ export function useKnowledgeGraph(projectId: string) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Hazard nodes
|
// Hazard nodes
|
||||||
|
const compIdSet = new Set(components.map((c) => c.id))
|
||||||
hazards.forEach((h) => {
|
hazards.forEach((h) => {
|
||||||
graphNodes.push({
|
graphNodes.push({
|
||||||
id: `haz-${h.id}`, type: 'hazard',
|
id: `haz-${h.id}`, type: 'hazard',
|
||||||
label: h.name, subLabel: h.category,
|
label: h.name, subLabel: h.category,
|
||||||
color: NODE_COLORS.hazard,
|
color: NODE_COLORS.hazard,
|
||||||
})
|
})
|
||||||
// Edge: first component → hazard (simplified — could be per component_id)
|
// Edge: the component that actually causes this hazard → hazard.
|
||||||
if (components.length > 0) {
|
// Only drawn when the hazard carries a component_id that maps to a known
|
||||||
|
// component node (no synthetic "all from the first component" edges).
|
||||||
|
if (h.component_id && compIdSet.has(h.component_id)) {
|
||||||
graphEdges.push({
|
graphEdges.push({
|
||||||
id: `e-comp-haz-${h.id}`,
|
id: `e-comp-haz-${h.id}`,
|
||||||
source: `comp-${components[0].id}`,
|
source: `comp-${h.component_id}`,
|
||||||
target: `haz-${h.id}`,
|
target: `haz-${h.id}`,
|
||||||
label: 'erzeugt',
|
label: 'erzeugt',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -134,9 +134,14 @@ export default function IACEDashboardPage() {
|
|||||||
machine_type: '',
|
machine_type: '',
|
||||||
manufacturer: '',
|
manufacturer: '',
|
||||||
})
|
})
|
||||||
|
const [machineTypes, setMachineTypes] = useState<{ key: string; label_de: string; group: string }[]>([])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchProjects()
|
fetchProjects()
|
||||||
|
fetch('/api/sdk/v1/iace/machine-types')
|
||||||
|
.then((r) => (r.ok ? r.json() : null))
|
||||||
|
.then((j) => j && setMachineTypes(j.machine_types || []))
|
||||||
|
.catch((err) => console.error('Failed to fetch machine types:', err))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
async function fetchProjects() {
|
async function fetchProjects() {
|
||||||
@@ -308,13 +313,23 @@ export default function IACEDashboardPage() {
|
|||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
Maschinentyp
|
Maschinentyp
|
||||||
</label>
|
</label>
|
||||||
<input
|
<select
|
||||||
type="text"
|
|
||||||
value={formData.machine_type}
|
value={formData.machine_type}
|
||||||
onChange={(e) => setFormData({ ...formData, machine_type: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, machine_type: e.target.value })}
|
||||||
placeholder="z.B. Industrieroboter"
|
|
||||||
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"
|
||||||
/>
|
>
|
||||||
|
<option value="">— Maschinentyp wählen —</option>
|
||||||
|
{Array.from(new Set(machineTypes.map((m) => m.group))).map((group) => (
|
||||||
|
<optgroup key={group} label={group}>
|
||||||
|
{machineTypes.filter((m) => m.group === group).map((m) => (
|
||||||
|
<option key={m.key} value={m.key}>{m.label_de}</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<p className="mt-1 text-xs text-gray-400">
|
||||||
|
Steuert, welche maschinenspezifischen Gefährdungs-Patterns greifen — bitte aus der Liste wählen.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
|||||||
Reference in New Issue
Block a user