feat: IACE CE-Compliance Module — Normen, Risikobewertung, Production Lines

Major features:
- 215 norms library with section references + Beuth URLs (A/B1/B2/C norms)
- 173 hazard patterns with detail fields (scenario, trigger, harm, zone)
- Deterministic pattern matching: Component × Lifecycle × Pattern cross-product
- SIL/PL auto-calculation from S×E×P risk graph
- Risk assessment table with editable S/E/P dropdowns
- Production Line Dashboard with animated station flow (Running Dots)
- IACE process flow + norms coverage on start page
- Non-blocking cookie banner, ProcessFlow SSR fix
- 104 Playwright E2E tests passing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-05-07 10:53:26 +02:00
parent 3853a0838a
commit e7f2f98da3
59 changed files with 8326 additions and 525 deletions
@@ -0,0 +1,74 @@
'use client'
import React from 'react'
import type { LineDashboard } from '../../_types'
interface AggregatePanelProps {
dashboard: LineDashboard
}
const RISK_DOTS = [
{ key: 'critical', label: 'Kritisch', dotColor: 'bg-red-500' },
{ key: 'high', label: 'Hoch', dotColor: 'bg-orange-500' },
{ key: 'medium', label: 'Mittel', dotColor: 'bg-yellow-500' },
{ key: 'low', label: 'Niedrig', dotColor: 'bg-green-500' },
]
export function AggregatePanel({ dashboard }: AggregatePanelProps) {
const { line, stations, aggregate } = dashboard
const totalHazards = stations.reduce((sum, s) => sum + s.hazard_count, 0)
const totalMitigations = stations.reduce((sum, s) => sum + s.mitigation_count, 0)
const stationCount = stations.length
return (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
{/* Title row */}
<div className="flex items-start justify-between mb-3">
<div>
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
{line.name}
</h1>
{line.description && (
<p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
{line.description}
</p>
)}
</div>
<div className="flex items-center gap-2 text-xs text-gray-400 dark:text-gray-500">
<span>Erstellt: {new Date(line.created_at).toLocaleDateString('de-DE')}</span>
</div>
</div>
{/* Stats row */}
<div className="flex flex-wrap items-center gap-6 mb-3">
<StatPill label="Stationen" value={stationCount} />
<StatPill label="Gefaehrdungen" value={totalHazards} />
<StatPill label="Massnahmen" value={totalMitigations} />
</div>
{/* Risk dots row */}
<div className="flex flex-wrap items-center gap-4">
{RISK_DOTS.map((rd) => {
const count = aggregate[rd.key] || 0
return (
<span key={rd.key} className="flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-300">
<span className={`w-2.5 h-2.5 rounded-full ${rd.dotColor}`} />
<span className="font-semibold">{count}</span>
<span>{rd.label}</span>
</span>
)
})}
</div>
</div>
)
}
function StatPill({ label, value }: { label: string; value: number }) {
return (
<div className="flex items-center gap-1.5 text-sm">
<span className="font-bold text-gray-900 dark:text-white">{value}</span>
<span className="text-gray-500 dark:text-gray-400">{label}</span>
</div>
)
}
@@ -0,0 +1,165 @@
'use client'
import React from 'react'
import Link from 'next/link'
import { StationIcon } from './StationIcons'
import { STATION_TYPES } from '../../_types'
import type { StationDashboard } from '../../_types'
const STATUS_COLORS: Record<string, string> = {
draft: 'bg-gray-100 text-gray-700',
in_progress: 'bg-blue-100 text-blue-700',
review: 'bg-yellow-100 text-yellow-700',
approved: 'bg-green-100 text-green-700',
archived: 'bg-gray-100 text-gray-500',
}
const STATUS_LABELS: Record<string, string> = {
draft: 'Entwurf',
in_progress: 'In Bearbeitung',
review: 'In Pruefung',
approved: 'Freigegeben',
archived: 'Archiviert',
}
const RISK_LEVELS = [
{ key: 'critical', label: 'Kritisch', color: 'bg-red-500', text: 'text-red-700' },
{ key: 'high', label: 'Hoch', color: 'bg-orange-500', text: 'text-orange-700' },
{ key: 'medium', label: 'Mittel', color: 'bg-yellow-500', text: 'text-yellow-700' },
{ key: 'low', label: 'Niedrig', color: 'bg-green-500', text: 'text-green-700' },
]
interface StationCardProps {
station: StationDashboard
expanded: boolean
onToggle: () => void
}
export function StationCard({ station, expanded, onToggle }: StationCardProps) {
const stationType = STATION_TYPES[station.station.station_type]
const bgColor = stationType?.bgColor || 'bg-gray-50'
const accentColor = stationType?.color || '#6B7280'
const totalRisk = Object.values(station.risk_summary).reduce((a, b) => a + b, 0)
const pctBar = station.completeness_pct
return (
<div
className={`w-56 flex-shrink-0 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 overflow-hidden shadow-sm hover:shadow-md transition-shadow`}
>
{/* Color accent bar */}
<div className="h-1.5" style={{ backgroundColor: accentColor }} />
{/* Collapsed content */}
<div className="p-4">
{/* Icon + name */}
<div className="flex items-center gap-3 mb-2">
<div
className={`w-10 h-10 ${bgColor} dark:bg-opacity-20 rounded-lg flex items-center justify-center flex-shrink-0`}
style={{ color: accentColor }}
>
<StationIcon type={station.station.station_type} size={22} />
</div>
<div className="min-w-0">
<div className="text-sm font-semibold text-gray-900 dark:text-white truncate">
{station.station.station_label}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
{station.project_name}
</div>
</div>
</div>
{/* Hazard count */}
<div className="text-xs text-gray-600 dark:text-gray-400 mb-2">
{station.hazard_count} Gefaehrdungen
</div>
{/* Completeness bar */}
<div className="flex items-center gap-2 mb-2">
<div className="flex-1 bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
className="h-2 rounded-full transition-all"
style={{
width: `${pctBar}%`,
backgroundColor: accentColor,
}}
/>
</div>
<span className="text-xs font-medium text-gray-600 dark:text-gray-400 w-8 text-right">
{pctBar}%
</span>
</div>
{/* SIL / PL */}
{(station.sil_max || station.pl_max) && (
<div className="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400 mb-3">
{station.sil_max && <span>SIL {station.sil_max}</span>}
{station.sil_max && station.pl_max && <span>|</span>}
{station.pl_max && <span>PL {station.pl_max}</span>}
</div>
)}
{/* Toggle button */}
<button
onClick={onToggle}
className="w-full text-left text-xs text-purple-600 dark:text-purple-400 hover:text-purple-700 dark:hover:text-purple-300 font-medium flex items-center gap-1"
>
{expanded ? 'Weniger anzeigen' : 'Details anzeigen'}
<svg
className={`w-3.5 h-3.5 transition-transform ${expanded ? 'rotate-90' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
{/* Expanded content */}
{expanded && (
<div className="px-4 pb-4 border-t border-gray-100 dark:border-gray-700 pt-3 space-y-3">
{/* Risk breakdown */}
<div className="space-y-1.5">
{RISK_LEVELS.map((level) => {
const count = station.risk_summary[level.key] || 0
const pct = totalRisk > 0 ? Math.round((count / totalRisk) * 100) : 0
return (
<div key={level.key} className="flex items-center gap-2">
<span className={`text-[10px] font-medium w-12 ${level.text}`}>{level.label}</span>
<div className="flex-1 bg-gray-100 dark:bg-gray-700 rounded-full h-2.5 overflow-hidden">
<div className={`${level.color} h-2.5 rounded-full`} style={{ width: `${pct}%` }} />
</div>
<span className="text-xs font-bold text-gray-700 dark:text-gray-300 w-6 text-right">{count}</span>
</div>
)
})}
</div>
{/* Mitigation count */}
<div className="flex items-center justify-between text-xs">
<span className="text-gray-500 dark:text-gray-400">Massnahmen</span>
<span className="font-semibold text-gray-700 dark:text-gray-300">{station.mitigation_count}</span>
</div>
{/* Status badge */}
<div className="flex items-center justify-between text-xs">
<span className="text-gray-500 dark:text-gray-400">Status</span>
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${STATUS_COLORS[station.status] || STATUS_COLORS.draft}`}>
{STATUS_LABELS[station.status] || station.status}
</span>
</div>
{/* Link to project */}
<Link
href={`/sdk/iace/${station.station.project_id}`}
className="block text-center text-xs font-medium text-purple-600 dark:text-purple-400 hover:text-purple-700 bg-purple-50 dark:bg-purple-900/20 rounded-lg py-2 transition-colors"
>
Zum Projekt &rarr;
</Link>
</div>
)}
</div>
)
}
@@ -0,0 +1,199 @@
import React from 'react'
interface StationIconProps {
type: string
size?: number
}
export function StationIcon({ type, size = 24 }: StationIconProps) {
const s = size
const sw = 1.5
switch (type) {
case 'press':
return (
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={sw} strokeLinecap="round" strokeLinejoin="round">
{/* Ram pressing down */}
<rect x="7" y="2" width="10" height="4" rx="1" />
<line x1="12" y1="6" x2="12" y2="12" />
<path d="M6 12h12v3H6z" />
<line x1="12" y1="12" x2="12" y2="10" strokeWidth={2.5} />
{/* Base block */}
<rect x="5" y="18" width="14" height="4" rx="1" />
{/* Workpiece */}
<rect x="9" y="15" width="6" height="3" rx="0.5" />
</svg>
)
case 'robot':
case 'cobot':
case 'collaborative_robot':
return (
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={sw} strokeLinecap="round" strokeLinejoin="round">
{/* Base */}
<rect x="8" y="19" width="8" height="3" rx="1" />
{/* Lower arm */}
<line x1="12" y1="19" x2="8" y2="13" />
{/* Joint */}
<circle cx="8" cy="13" r="1.5" />
{/* Upper arm */}
<line x1="8" y1="13" x2="15" y2="7" />
{/* Wrist joint */}
<circle cx="15" cy="7" r="1.5" />
{/* Gripper */}
<line x1="15" y1="7" x2="18" y2="4" />
<line x1="18" y1="4" x2="19" y2="3" />
<line x1="18" y1="4" x2="19" y2="5" />
</svg>
)
case 'conveyor':
return (
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={sw} strokeLinecap="round" strokeLinejoin="round">
{/* Belt top */}
<line x1="3" y1="14" x2="21" y2="14" />
{/* Belt bottom */}
<line x1="3" y1="18" x2="21" y2="18" />
{/* Left roller */}
<circle cx="5" cy="16" r="2" />
{/* Right roller */}
<circle cx="19" cy="16" r="2" />
{/* Flow arrows */}
<path d="M8 10l3-2 3 2" />
<path d="M11 8v-2" />
{/* Package on belt */}
<rect x="9" y="10" width="6" height="4" rx="0.5" />
</svg>
)
case 'assembly':
return (
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={sw} strokeLinecap="round" strokeLinejoin="round">
{/* Gear */}
<circle cx="12" cy="12" r="4" />
<circle cx="12" cy="12" r="1.5" />
{/* Gear teeth */}
<line x1="12" y1="3" x2="12" y2="6" />
<line x1="12" y1="18" x2="12" y2="21" />
<line x1="3" y1="12" x2="6" y2="12" />
<line x1="18" y1="12" x2="21" y2="12" />
<line x1="5.6" y1="5.6" x2="7.8" y2="7.8" />
<line x1="16.2" y1="16.2" x2="18.4" y2="18.4" />
<line x1="5.6" y1="18.4" x2="7.8" y2="16.2" />
<line x1="16.2" y1="7.8" x2="18.4" y2="5.6" />
</svg>
)
case 'milling':
return (
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={sw} strokeLinecap="round" strokeLinejoin="round">
{/* Spindle */}
<rect x="10" y="2" width="4" height="6" rx="1" />
{/* Cutter head */}
<circle cx="12" cy="11" r="3" />
{/* Rotation arc */}
<path d="M7 11a5 5 0 0 1 2.5-4.3" strokeDasharray="2 2" />
<path d="M17 11a5 5 0 0 0-2.5-4.3" strokeDasharray="2 2" />
{/* Workpiece / table */}
<rect x="4" y="17" width="16" height="3" rx="1" />
<rect x="8" y="14" width="8" height="3" rx="0.5" />
</svg>
)
case 'turning':
return (
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={sw} strokeLinecap="round" strokeLinejoin="round">
{/* Chuck / rotating workpiece */}
<circle cx="9" cy="12" r="5" />
<circle cx="9" cy="12" r="2" />
{/* Tool holder */}
<line x1="16" y1="12" x2="14" y2="12" />
<path d="M16 9v6l4-1v-4z" />
{/* Rotation arrow */}
<path d="M5 5a8 8 0 0 1 4 1" />
<path d="M5 5l1.5 1.5L4.5 7" />
</svg>
)
case 'welding':
return (
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={sw} strokeLinecap="round" strokeLinejoin="round">
{/* Torch body */}
<line x1="6" y1="4" x2="12" y2="14" />
<path d="M4 3h4l-2 3z" />
{/* Weld point */}
<circle cx="12" cy="16" r="1" fill="currentColor" />
{/* Sparks */}
<line x1="12" y1="16" x2="15" y2="13" />
<line x1="12" y1="16" x2="16" y2="15" />
<line x1="12" y1="16" x2="14" y2="19" />
<line x1="12" y1="16" x2="9" y2="19" />
{/* Workpiece */}
<rect x="3" y="19" width="18" height="3" rx="1" />
</svg>
)
case 'inspection':
return (
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={sw} strokeLinecap="round" strokeLinejoin="round">
{/* Magnifying glass */}
<circle cx="10" cy="10" r="6" />
<line x1="14.5" y1="14.5" x2="20" y2="20" strokeWidth={2} />
{/* Checkmark inside */}
<path d="M7.5 10l2 2 3.5-4" />
</svg>
)
case 'packaging':
return (
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={sw} strokeLinecap="round" strokeLinejoin="round">
{/* Box */}
<path d="M3 8l9-5 9 5v10l-9 5-9-5z" />
<line x1="12" y1="3" x2="12" y2="23" />
<line x1="3" y1="8" x2="12" y2="13" />
<line x1="21" y1="8" x2="12" y2="13" />
</svg>
)
case 'motor':
case 'electric_motor':
return (
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={sw} strokeLinecap="round" strokeLinejoin="round">
{/* Motor body circle */}
<circle cx="12" cy="12" r="8" />
{/* Lightning bolt */}
<path d="M13 6l-3 6h4l-3 6" strokeWidth={2} />
{/* Shaft */}
<line x1="20" y1="12" x2="23" y2="12" strokeWidth={2} />
</svg>
)
case 'rotary_transfer':
case 'rotary_transfer_machine':
return (
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={sw} strokeLinecap="round" strokeLinejoin="round">
{/* Circular path */}
<circle cx="12" cy="12" r="8" strokeDasharray="4 2" />
{/* Center */}
<circle cx="12" cy="12" r="2" />
{/* Station dots around circle */}
<circle cx="12" cy="4" r="1.5" fill="currentColor" />
<circle cx="19" cy="8" r="1.5" fill="currentColor" />
<circle cx="19" cy="16" r="1.5" fill="currentColor" />
<circle cx="12" cy="20" r="1.5" fill="currentColor" />
<circle cx="5" cy="16" r="1.5" fill="currentColor" />
<circle cx="5" cy="8" r="1.5" fill="currentColor" />
{/* Rotation arrow */}
<path d="M16 3.5l-1 2h2z" fill="currentColor" />
</svg>
)
default:
return (
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={sw} strokeLinecap="round" strokeLinejoin="round">
<rect x="4" y="4" width="16" height="16" rx="2" />
<circle cx="12" cy="12" r="3" />
</svg>
)
}
}
@@ -0,0 +1,59 @@
'use client'
import React from 'react'
import type { TransferInfo } from '../../_types'
interface TransferLineProps {
transfer: TransferInfo
color: string
}
export function TransferLine({ transfer, color }: TransferLineProps) {
return (
<div className="flex flex-col items-center justify-center w-20 flex-shrink-0 py-4">
<style>{`
@keyframes iace-running-dots {
0% { stroke-dashoffset: 12; }
100% { stroke-dashoffset: 0; }
}
.iace-transfer-dots {
animation: iace-running-dots 0.8s linear infinite;
}
`}</style>
<svg width="80" height="32" viewBox="0 0 80 32" className="overflow-visible">
{/* Background line */}
<line
x1="0"
y1="16"
x2="80"
y2="16"
stroke={color}
strokeWidth="2"
strokeOpacity="0.3"
/>
{/* Animated running dots */}
<line
x1="0"
y1="16"
x2="80"
y2="16"
stroke={color}
strokeWidth="2.5"
strokeDasharray="4 8"
className="iace-transfer-dots"
/>
{/* Arrowhead */}
<polygon
points="74,11 80,16 74,21"
fill={color}
/>
</svg>
{/* Label */}
{transfer.label && (
<span className="text-[10px] text-gray-500 dark:text-gray-400 mt-1 text-center leading-tight max-w-[80px] truncate">
{transfer.label}
</span>
)}
</div>
)
}
@@ -0,0 +1,194 @@
'use client'
import React, { useState, useEffect, useCallback } from 'react'
import Link from 'next/link'
import { useParams } from 'next/navigation'
import { AggregatePanel } from './_components/AggregatePanel'
import { StationCard } from './_components/StationCard'
import { TransferLine } from './_components/TransferLine'
import type { LineDashboard, StationDashboard, TransferInfo } from '../_types'
import { TRANSFER_COLORS } from '../_types'
/** Number of stations per visual row before wrapping */
const STATIONS_PER_ROW = 4
export default function LineDashboardPage() {
const params = useParams()
const lineId = params.lineId as string
const [dashboard, setDashboard] = useState<LineDashboard | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [expandedStation, setExpandedStation] = useState<string | null>(null)
const fetchDashboard = useCallback(async () => {
try {
const res = await fetch(`/api/sdk/v1/iace/production-lines/${lineId}/dashboard`)
if (!res.ok) {
setError('Produktionslinie konnte nicht geladen werden')
return
}
const json = await res.json()
setDashboard(json)
} catch (err) {
console.error('Failed to fetch line dashboard:', err)
setError('Verbindung zum Backend fehlgeschlagen')
} finally {
setLoading(false)
}
}, [lineId])
useEffect(() => {
fetchDashboard()
}, [fetchDashboard])
function handleToggle(stationId: string) {
setExpandedStation((prev) => (prev === stationId ? null : stationId))
}
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
</div>
)
}
if (error || !dashboard) {
return (
<div className="text-center py-12 space-y-3">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
{error || 'Produktionslinie nicht gefunden'}
</h2>
<Link href="/sdk/iace/lines" className="text-purple-600 hover:text-purple-700">
Zurueck zur Uebersicht
</Link>
</div>
)
}
const sortedStations = [...dashboard.stations].sort(
(a, b) => a.station.sort_order - b.station.sort_order
)
// Build rows of stations for display
const rows = buildStationRows(sortedStations, STATIONS_PER_ROW)
return (
<div className="space-y-6 max-w-7xl mx-auto">
{/* Back link */}
<Link
href="/sdk/iace/lines"
className="inline-flex items-center gap-1 text-sm text-purple-600 hover:text-purple-700 dark:text-purple-400 dark:hover:text-purple-300 font-medium"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Alle Produktionslinien
</Link>
{/* Aggregate panel */}
<AggregatePanel dashboard={dashboard} />
{/* Station flow */}
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<h2 className="text-sm font-semibold text-gray-900 dark:text-white mb-4">
Stationsuebersicht
</h2>
<div className="space-y-6 overflow-x-auto">
{rows.map((row, rowIndex) => (
<StationRow
key={rowIndex}
stations={row}
transfers={dashboard.transfers}
expandedStation={expandedStation}
onToggle={handleToggle}
reversed={rowIndex % 2 === 1}
/>
))}
</div>
</div>
</div>
)
}
/** Split sorted stations into rows of N for layout */
function buildStationRows(
stations: StationDashboard[],
perRow: number
): StationDashboard[][] {
const rows: StationDashboard[][] = []
for (let i = 0; i < stations.length; i += perRow) {
rows.push(stations.slice(i, i + perRow))
}
return rows
}
/** Find the transfer between two adjacent station sort orders */
function findTransfer(
transfers: TransferInfo[],
fromOrder: number,
toOrder: number
): TransferInfo | null {
return (
transfers.find(
(t) => t.from_station === fromOrder && t.to_station === toOrder
) || null
)
}
/** Default transfer for stations without an explicit transfer entry */
function defaultTransfer(from: number, to: number): TransferInfo {
return { from_station: from, to_station: to, type: 'conveyor', label: '' }
}
interface StationRowProps {
stations: StationDashboard[]
transfers: TransferInfo[]
expandedStation: string | null
onToggle: (id: string) => void
reversed: boolean
}
function StationRow({ stations, transfers, expandedStation, onToggle, reversed }: StationRowProps) {
// Reverse even rows for a serpentine layout
const ordered = reversed ? [...stations].reverse() : stations
return (
<div className="flex items-start gap-0 overflow-x-auto pb-2">
{ordered.map((station, idx) => {
const nextStation = ordered[idx + 1]
const transfer = nextStation
? findTransfer(
transfers,
station.station.sort_order,
nextStation.station.sort_order
) ||
findTransfer(
transfers,
nextStation.station.sort_order,
station.station.sort_order
) ||
defaultTransfer(station.station.sort_order, nextStation.station.sort_order)
: null
const transferColor = transfer
? TRANSFER_COLORS[transfer.type] || TRANSFER_COLORS.conveyor
: '#22C55E'
return (
<React.Fragment key={station.station.id}>
<StationCard
station={station}
expanded={expandedStation === station.station.id}
onToggle={() => onToggle(station.station.id)}
/>
{transfer && (
<TransferLine transfer={transfer} color={transferColor} />
)}
</React.Fragment>
)
})}
</div>
)
}
@@ -0,0 +1,66 @@
export interface ProductionLine {
id: string
name: string
description: string
created_at: string
}
export interface StationDashboard {
station: {
id: string
line_id: string
project_id: string
station_type: string
station_label: string
sort_order: number
}
project_name: string
machine_type: string
status: string
risk_summary: Record<string, number>
hazard_count: number
mitigation_count: number
completeness_pct: number
sil_max: string
pl_max: string
}
export interface TransferInfo {
from_station: number
to_station: number
type: string
label: string
}
export interface LineDashboard {
line: ProductionLine
stations: StationDashboard[]
transfers: TransferInfo[]
aggregate: Record<string, number>
}
export const STATION_TYPES: Record<string, { label: string; color: string; bgColor: string }> = {
press: { label: 'Presse', color: '#EF4444', bgColor: 'bg-red-50' },
robot: { label: 'Roboter', color: '#3B82F6', bgColor: 'bg-blue-50' },
cobot: { label: 'Cobot', color: '#3B82F6', bgColor: 'bg-blue-50' },
collaborative_robot: { label: 'Cobot', color: '#3B82F6', bgColor: 'bg-blue-50' },
conveyor: { label: 'Foerderer', color: '#22C55E', bgColor: 'bg-green-50' },
assembly: { label: 'Montage', color: '#F97316', bgColor: 'bg-orange-50' },
milling: { label: 'Fraese', color: '#8B5CF6', bgColor: 'bg-purple-50' },
turning: { label: 'Drehmaschine', color: '#1D4ED8', bgColor: 'bg-blue-50' },
welding: { label: 'Schweissen', color: '#EAB308', bgColor: 'bg-yellow-50' },
inspection: { label: 'Pruefung', color: '#06B6D4', bgColor: 'bg-cyan-50' },
packaging: { label: 'Verpackung', color: '#92400E', bgColor: 'bg-amber-50' },
motor: { label: 'Motor', color: '#6B7280', bgColor: 'bg-gray-50' },
electric_motor: { label: 'Elektromotor', color: '#6B7280', bgColor: 'bg-gray-50' },
rotary_transfer: { label: 'Rundtakt', color: '#7C3AED', bgColor: 'bg-violet-50' },
rotary_transfer_machine: { label: 'Rundtakt', color: '#7C3AED', bgColor: 'bg-violet-50' },
}
export const TRANSFER_COLORS: Record<string, string> = {
conveyor: '#22C55E',
robot: '#3B82F6',
manual: '#EAB308',
crane: '#F97316',
agv: '#8B5CF6',
}
@@ -0,0 +1,135 @@
'use client'
import React, { useState, useEffect } from 'react'
import Link from 'next/link'
interface ProductionLineItem {
id: string
name: string
description: string
station_count: number
created_at: string
updated_at: string
}
export default function ProductionLinesListPage() {
const [lines, setLines] = useState<ProductionLineItem[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
fetchLines()
}, [])
async function fetchLines() {
try {
const res = await fetch('/api/sdk/v1/iace/production-lines')
if (res.ok) {
const json = await res.json()
setLines(json.lines || json || [])
}
} catch (err) {
console.error('Failed to fetch production lines:', err)
} finally {
setLoading(false)
}
}
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
</div>
)
}
return (
<div className="space-y-6 max-w-6xl mx-auto">
{/* Header */}
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2 mb-1">
<Link
href="/sdk/iace"
className="text-xs text-purple-600 hover:text-purple-700 font-medium flex items-center gap-1"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
IACE
</Link>
</div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
Produktionslinien
</h1>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Verkettete Fertigungsstrassen mit aggregierter Risikoansicht
</p>
</div>
<Link
href="/sdk/iace/lines/new"
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors flex-shrink-0"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Neue Produktionslinie
</Link>
</div>
{/* Lines list */}
{lines.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{lines.map((line) => (
<Link
key={line.id}
href={`/sdk/iace/lines/${line.id}`}
className="block bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 hover:shadow-md hover:border-purple-300 transition-all"
>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-1">
{line.name}
</h3>
{line.description && (
<p className="text-sm text-gray-500 dark:text-gray-400 mb-3 line-clamp-2">
{line.description}
</p>
)}
<div className="flex items-center gap-4 text-xs text-gray-500 dark:text-gray-400">
<span className="flex items-center gap-1">
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
{line.station_count} Stationen
</span>
<span>
Aktualisiert: {new Date(line.updated_at || line.created_at).toLocaleDateString('de-DE')}
</span>
</div>
</Link>
))}
</div>
) : (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-12 text-center">
<div className="w-16 h-16 mx-auto bg-purple-100 dark:bg-purple-900/30 rounded-full flex items-center justify-center mb-4">
<svg className="w-8 h-8 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
Noch keine Produktionslinien vorhanden
</h3>
<p className="mt-2 text-gray-500 dark:text-gray-400 max-w-lg mx-auto">
Produktionslinien verketten mehrere CE-Projekte zu einer Fertigungsstrasse.
Sie sehen auf einen Blick den Risikostatus aller Stationen und koennen
Massnahmen priorisieren.
</p>
<Link
href="/sdk/iace/lines/new"
className="inline-block mt-6 px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors font-medium"
>
Erste Produktionslinie erstellen
</Link>
</div>
)}
</div>
)
}