feat: IACE Bibliotheks-Browser — 751 Normen, 1000 Patterns, 200 Massnahmen
Neue Seite /sdk/iace/library mit 3 Tabs: - Normen: Suche + Filter A/B/C + Pflicht + Beuth-Links - Patterns: Suche + Filter Kategorie/Prioritaet + Details aufklappbar - Massnahmen: Suche + Filter Design/Schutz/Information Alle mit Pagination (50/Seite) und Zaehler-Badges. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
|
||||
// ---------- Pagination ----------
|
||||
interface PaginationProps {
|
||||
page: number
|
||||
totalPages: number
|
||||
onPageChange: (p: number) => void
|
||||
}
|
||||
|
||||
export function Pagination({ page, totalPages, onPageChange }: PaginationProps) {
|
||||
if (totalPages <= 1) return null
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
disabled={page <= 1}
|
||||
className="px-3 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 disabled:opacity-40 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
‹ Zurueck
|
||||
</button>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Seite {page} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
disabled={page >= totalPages}
|
||||
className="px-3 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 disabled:opacity-40 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
Weiter ›
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------- Search ----------
|
||||
interface SearchInputProps {
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
export function SearchInput({ value, onChange, placeholder }: SearchInputProps) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder || 'Suchen...'}
|
||||
className="w-full pl-10 pr-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------- Filter Dropdown ----------
|
||||
interface FilterDropdownProps {
|
||||
label: string
|
||||
value: string
|
||||
options: { value: string; label: string }[]
|
||||
onChange: (v: string) => void
|
||||
}
|
||||
|
||||
export function FilterDropdown({ label, value, options, onChange }: FilterDropdownProps) {
|
||||
return (
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
aria-label={label}
|
||||
className="px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
>
|
||||
{options.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------- Expandable Row ----------
|
||||
interface ExpandableRowProps {
|
||||
cells: React.ReactNode[]
|
||||
expandedContent: React.ReactNode
|
||||
colSpan: number
|
||||
}
|
||||
|
||||
export function ExpandableRow({ cells, expandedContent, colSpan }: ExpandableRowProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
return (
|
||||
<>
|
||||
<tr
|
||||
onClick={() => setOpen(!open)}
|
||||
className="cursor-pointer hover:bg-purple-50/50 dark:hover:bg-purple-900/10 transition-colors even:bg-gray-50/50 dark:even:bg-gray-800/30"
|
||||
>
|
||||
{cells.map((cell, i) => (
|
||||
<td key={i} className="px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 whitespace-nowrap">
|
||||
{cell}
|
||||
</td>
|
||||
))}
|
||||
<td className="px-3 py-2.5 text-gray-400">
|
||||
<svg className={`w-4 h-4 transition-transform ${open ? '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>
|
||||
</td>
|
||||
</tr>
|
||||
{open && (
|
||||
<tr className="bg-purple-50/30 dark:bg-purple-900/5">
|
||||
<td colSpan={colSpan + 1} className="px-6 py-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
{expandedContent}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------- External Link Icon ----------
|
||||
export function ExternalLinkIcon() {
|
||||
return (
|
||||
<svg className="w-3.5 h-3.5 inline-block ml-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
'use client'
|
||||
|
||||
import React, { useMemo, useState, useRef, useEffect } from 'react'
|
||||
import { SearchInput, FilterDropdown, Pagination, ExpandableRow } from './LibraryTable'
|
||||
|
||||
export interface ProtectiveMeasure {
|
||||
id: string
|
||||
reduction_type: string
|
||||
sub_type: string
|
||||
name: string
|
||||
description: string
|
||||
hazard_category: string
|
||||
examples: string[]
|
||||
norm_references: string[]
|
||||
}
|
||||
|
||||
const PER_PAGE = 50
|
||||
const TYPE_OPTIONS = [
|
||||
{ value: '', label: 'Alle Typen' },
|
||||
{ value: 'Design', label: 'Design' },
|
||||
{ value: 'Schutz', label: 'Schutz' },
|
||||
{ value: 'Information', label: 'Information' },
|
||||
]
|
||||
|
||||
function typeColor(t: string): string {
|
||||
switch (t) {
|
||||
case 'Design': return 'bg-blue-100 text-blue-800'
|
||||
case 'Schutz': return 'bg-green-100 text-green-800'
|
||||
case 'Information': return 'bg-yellow-100 text-yellow-800'
|
||||
default: return 'bg-gray-100 text-gray-700'
|
||||
}
|
||||
}
|
||||
|
||||
interface Props { measures: ProtectiveMeasure[] }
|
||||
|
||||
export default function MeasuresTab({ measures }: Props) {
|
||||
const [search, setSearch] = useState('')
|
||||
const [debounced, setDebounced] = useState('')
|
||||
const [typeFilter, setTypeFilter] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
timer.current = setTimeout(() => setDebounced(search), 300)
|
||||
return () => { if (timer.current) clearTimeout(timer.current) }
|
||||
}, [search])
|
||||
|
||||
useEffect(() => { setPage(1) }, [debounced, typeFilter])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = debounced.toLowerCase()
|
||||
return measures.filter((m) => {
|
||||
if (q && !m.name.toLowerCase().includes(q) && !m.description.toLowerCase().includes(q)) return false
|
||||
if (typeFilter && m.reduction_type !== typeFilter) return false
|
||||
return true
|
||||
})
|
||||
}, [measures, debounced, typeFilter])
|
||||
|
||||
const totalPages = Math.ceil(filtered.length / PER_PAGE)
|
||||
const pageItems = filtered.slice((page - 1) * PER_PAGE, page * PER_PAGE)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<SearchInput value={search} onChange={setSearch} placeholder="Name oder Beschreibung suchen..." />
|
||||
</div>
|
||||
<FilterDropdown label="Typ" value={typeFilter} options={TYPE_OPTIONS} onChange={setTypeFilter} />
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400 ml-auto">
|
||||
{measures.length} Massnahmen{filtered.length !== measures.length && ` (${filtered.length} gefiltert)`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<table className="w-full text-left">
|
||||
<thead className="bg-gray-100 dark:bg-gray-800">
|
||||
<tr>
|
||||
{['ID', 'Name', 'Typ', 'Subtyp', 'Kategorie', 'Normen'].map((h) => (
|
||||
<th key={h} className="px-4 py-2.5 text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider">{h}</th>
|
||||
))}
|
||||
<th className="w-8" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pageItems.map((m) => (
|
||||
<ExpandableRow
|
||||
key={m.id}
|
||||
colSpan={6}
|
||||
cells={[
|
||||
<span key="id" className="font-mono text-xs text-gray-500">{m.id.slice(0, 8)}</span>,
|
||||
<span key="name" className="max-w-xs truncate block">{m.name}</span>,
|
||||
<span key="type" className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${typeColor(m.reduction_type)}`}>{m.reduction_type}</span>,
|
||||
<span key="sub" className="text-xs">{m.sub_type || '-'}</span>,
|
||||
<span key="cat" className="text-xs">{m.hazard_category?.replace(/_/g, ' ') || '-'}</span>,
|
||||
<span key="norms" className="text-xs text-gray-500">{m.norm_references.length > 0 ? m.norm_references.slice(0, 2).join(', ') : '-'}{m.norm_references.length > 2 ? ` +${m.norm_references.length - 2}` : ''}</span>,
|
||||
]}
|
||||
expandedContent={
|
||||
<div className="space-y-2">
|
||||
{m.description && <div><span className="font-medium text-gray-700 dark:text-gray-300">Beschreibung:</span> {m.description}</div>}
|
||||
{m.examples.length > 0 && (
|
||||
<div>
|
||||
<span className="font-medium text-gray-700 dark:text-gray-300">Beispiele:</span>
|
||||
<ul className="list-disc ml-5 mt-1 space-y-0.5">
|
||||
{m.examples.map((ex, i) => <li key={i}>{ex}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{m.norm_references.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{m.norm_references.map((nr) => (
|
||||
<span key={nr} className="px-1.5 py-0.5 rounded text-xs bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 font-mono">{nr}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{pageItems.length === 0 && (
|
||||
<tr><td colSpan={7} className="px-4 py-8 text-center text-sm text-gray-400">Keine Massnahmen gefunden</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
'use client'
|
||||
|
||||
import React, { useMemo, useState, useRef, useEffect } from 'react'
|
||||
import { SearchInput, FilterDropdown, Pagination, ExpandableRow, ExternalLinkIcon } from './LibraryTable'
|
||||
|
||||
export interface Norm {
|
||||
id: string
|
||||
number: string
|
||||
title_de: string
|
||||
norm_type: string
|
||||
scope_de: string
|
||||
machine_types: string[]
|
||||
hazard_cats: string[]
|
||||
tags: string[]
|
||||
mandatory: boolean
|
||||
relevant_sections: string[]
|
||||
beuth_url: string
|
||||
}
|
||||
|
||||
const PER_PAGE = 50
|
||||
const TYPE_OPTIONS = [
|
||||
{ value: '', label: 'Alle Typen' },
|
||||
{ value: 'A', label: 'A-Normen' },
|
||||
{ value: 'B1', label: 'B1-Normen' },
|
||||
{ value: 'B2', label: 'B2-Normen' },
|
||||
{ value: 'C', label: 'C-Normen' },
|
||||
]
|
||||
const MANDATORY_OPTIONS = [
|
||||
{ value: '', label: 'Pflicht: Alle' },
|
||||
{ value: 'ja', label: 'Pflicht: Ja' },
|
||||
{ value: 'nein', label: 'Pflicht: Nein' },
|
||||
]
|
||||
|
||||
interface Props { norms: Norm[] }
|
||||
|
||||
export default function NormenTab({ norms }: Props) {
|
||||
const [search, setSearch] = useState('')
|
||||
const [debounced, setDebounced] = useState('')
|
||||
const [typeFilter, setTypeFilter] = useState('')
|
||||
const [mandatoryFilter, setMandatoryFilter] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
timer.current = setTimeout(() => setDebounced(search), 300)
|
||||
return () => { if (timer.current) clearTimeout(timer.current) }
|
||||
}, [search])
|
||||
|
||||
useEffect(() => { setPage(1) }, [debounced, typeFilter, mandatoryFilter])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = debounced.toLowerCase()
|
||||
return norms.filter((n) => {
|
||||
if (q && !n.number.toLowerCase().includes(q) && !n.title_de.toLowerCase().includes(q) && !n.scope_de.toLowerCase().includes(q)) return false
|
||||
if (typeFilter && n.norm_type !== typeFilter) return false
|
||||
if (mandatoryFilter === 'ja' && !n.mandatory) return false
|
||||
if (mandatoryFilter === 'nein' && n.mandatory) return false
|
||||
return true
|
||||
})
|
||||
}, [norms, debounced, typeFilter, mandatoryFilter])
|
||||
|
||||
const totalPages = Math.ceil(filtered.length / PER_PAGE)
|
||||
const pageItems = filtered.slice((page - 1) * PER_PAGE, page * PER_PAGE)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<SearchInput value={search} onChange={setSearch} placeholder="Nummer, Titel oder Scope suchen..." />
|
||||
</div>
|
||||
<FilterDropdown label="Normtyp" value={typeFilter} options={TYPE_OPTIONS} onChange={setTypeFilter} />
|
||||
<FilterDropdown label="Pflicht" value={mandatoryFilter} options={MANDATORY_OPTIONS} onChange={setMandatoryFilter} />
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400 ml-auto">
|
||||
{norms.length} Normen{filtered.length !== norms.length && ` (${filtered.length} gefiltert)`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<table className="w-full text-left">
|
||||
<thead className="bg-gray-100 dark:bg-gray-800">
|
||||
<tr>
|
||||
{['Nummer', 'Titel', 'Typ', 'Abschnitte', 'Pflicht', 'Beuth'].map((h) => (
|
||||
<th key={h} className="px-4 py-2.5 text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider">{h}</th>
|
||||
))}
|
||||
<th className="w-8" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pageItems.map((n) => (
|
||||
<ExpandableRow
|
||||
key={n.id}
|
||||
colSpan={6}
|
||||
cells={[
|
||||
<span key="num" className="font-mono text-xs">{n.number}</span>,
|
||||
<span key="title" className="max-w-xs truncate block">{n.title_de}</span>,
|
||||
<span key="type" className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300">{n.norm_type}</span>,
|
||||
<span key="sec" className="text-xs text-gray-500">{n.relevant_sections.length > 0 ? n.relevant_sections.slice(0, 3).join(', ') : '-'}{n.relevant_sections.length > 3 ? ` +${n.relevant_sections.length - 3}` : ''}</span>,
|
||||
n.mandatory
|
||||
? <span key="mand" className="text-green-600 font-medium text-xs">Ja</span>
|
||||
: <span key="mand" className="text-gray-400 text-xs">Nein</span>,
|
||||
n.beuth_url
|
||||
? <a key="link" href={n.beuth_url} target="_blank" rel="noopener noreferrer" onClick={(e) => e.stopPropagation()} className="text-purple-600 hover:text-purple-800 text-xs">Link<ExternalLinkIcon /></a>
|
||||
: <span key="link" className="text-gray-300 text-xs">-</span>,
|
||||
]}
|
||||
expandedContent={
|
||||
<div className="space-y-2">
|
||||
<div><span className="font-medium text-gray-700 dark:text-gray-300">Scope:</span> {n.scope_de || '-'}</div>
|
||||
{n.relevant_sections.length > 0 && (
|
||||
<div><span className="font-medium text-gray-700 dark:text-gray-300">Abschnitte:</span> {n.relevant_sections.join(', ')}</div>
|
||||
)}
|
||||
{n.machine_types.length > 0 && (
|
||||
<div><span className="font-medium text-gray-700 dark:text-gray-300">Maschinentypen:</span> {n.machine_types.join(', ')}</div>
|
||||
)}
|
||||
{n.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{n.tags.map((t) => <span key={t} className="px-1.5 py-0.5 rounded text-xs bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300">{t}</span>)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{pageItems.length === 0 && (
|
||||
<tr><td colSpan={7} className="px-4 py-8 text-center text-sm text-gray-400">Keine Normen gefunden</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
'use client'
|
||||
|
||||
import React, { useMemo, useState, useRef, useEffect } from 'react'
|
||||
import { SearchInput, FilterDropdown, Pagination, ExpandableRow } from './LibraryTable'
|
||||
|
||||
export interface HazardPattern {
|
||||
id: string
|
||||
name_de: string
|
||||
name_en: string
|
||||
priority: number
|
||||
required_component_tags: string[]
|
||||
generated_hazard_categories: string[]
|
||||
scenario_de: string
|
||||
trigger_de: string
|
||||
harm_de: string
|
||||
affected_de?: string
|
||||
zone_de?: string
|
||||
}
|
||||
|
||||
const PER_PAGE = 50
|
||||
const PRIORITY_OPTIONS = [
|
||||
{ value: '', label: 'Alle Prioritaeten' },
|
||||
{ value: '90', label: 'Prioritaet > 90' },
|
||||
{ value: '70', label: 'Prioritaet > 70' },
|
||||
{ value: '50', label: 'Prioritaet > 50' },
|
||||
]
|
||||
|
||||
function priorityColor(p: number): string {
|
||||
if (p >= 90) return 'bg-red-100 text-red-800'
|
||||
if (p >= 70) return 'bg-orange-100 text-orange-800'
|
||||
if (p >= 50) return 'bg-yellow-100 text-yellow-800'
|
||||
return 'bg-gray-100 text-gray-700'
|
||||
}
|
||||
|
||||
interface Props { patterns: HazardPattern[] }
|
||||
|
||||
export default function PatternsTab({ patterns }: Props) {
|
||||
const [search, setSearch] = useState('')
|
||||
const [debounced, setDebounced] = useState('')
|
||||
const [catFilter, setCatFilter] = useState('')
|
||||
const [prioFilter, setPrioFilter] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
timer.current = setTimeout(() => setDebounced(search), 300)
|
||||
return () => { if (timer.current) clearTimeout(timer.current) }
|
||||
}, [search])
|
||||
|
||||
useEffect(() => { setPage(1) }, [debounced, catFilter, prioFilter])
|
||||
|
||||
const categories = useMemo(() => {
|
||||
const set = new Set<string>()
|
||||
patterns.forEach((p) => p.generated_hazard_categories.forEach((c) => set.add(c)))
|
||||
return Array.from(set).sort()
|
||||
}, [patterns])
|
||||
|
||||
const catOptions = useMemo(() => [
|
||||
{ value: '', label: 'Alle Kategorien' },
|
||||
...categories.map((c) => ({ value: c, label: c.replace(/_/g, ' ') })),
|
||||
], [categories])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = debounced.toLowerCase()
|
||||
const prioMin = prioFilter ? parseInt(prioFilter) : 0
|
||||
return patterns.filter((p) => {
|
||||
if (q && !p.name_de.toLowerCase().includes(q) && !p.scenario_de.toLowerCase().includes(q) && !p.harm_de.toLowerCase().includes(q)) return false
|
||||
if (catFilter && !p.generated_hazard_categories.includes(catFilter)) return false
|
||||
if (prioMin && p.priority <= prioMin) return false
|
||||
return true
|
||||
})
|
||||
}, [patterns, debounced, catFilter, prioFilter])
|
||||
|
||||
const totalPages = Math.ceil(filtered.length / PER_PAGE)
|
||||
const pageItems = filtered.slice((page - 1) * PER_PAGE, page * PER_PAGE)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<SearchInput value={search} onChange={setSearch} placeholder="Name, Szenario oder Schaden suchen..." />
|
||||
</div>
|
||||
<FilterDropdown label="Kategorie" value={catFilter} options={catOptions} onChange={setCatFilter} />
|
||||
<FilterDropdown label="Prioritaet" value={prioFilter} options={PRIORITY_OPTIONS} onChange={setPrioFilter} />
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400 ml-auto">
|
||||
{patterns.length} Patterns{filtered.length !== patterns.length && ` (${filtered.length} gefiltert)`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<table className="w-full text-left">
|
||||
<thead className="bg-gray-100 dark:bg-gray-800">
|
||||
<tr>
|
||||
{['ID', 'Name', 'Kategorie', 'Prioritaet', 'Schaden', 'Tags'].map((h) => (
|
||||
<th key={h} className="px-4 py-2.5 text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider">{h}</th>
|
||||
))}
|
||||
<th className="w-8" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pageItems.map((p) => (
|
||||
<ExpandableRow
|
||||
key={p.id}
|
||||
colSpan={6}
|
||||
cells={[
|
||||
<span key="id" className="font-mono text-xs text-gray-500">{p.id.slice(0, 8)}</span>,
|
||||
<span key="name" className="max-w-xs truncate block">{p.name_de}</span>,
|
||||
<span key="cat" className="text-xs">{p.generated_hazard_categories[0]?.replace(/_/g, ' ') || '-'}</span>,
|
||||
<span key="prio" className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${priorityColor(p.priority)}`}>{p.priority}</span>,
|
||||
<span key="harm" className="max-w-[160px] truncate block text-xs">{p.harm_de || '-'}</span>,
|
||||
<span key="tags" className="text-xs text-gray-500">{p.required_component_tags.length > 0 ? p.required_component_tags.slice(0, 2).join(', ') : '-'}{p.required_component_tags.length > 2 ? ` +${p.required_component_tags.length - 2}` : ''}</span>,
|
||||
]}
|
||||
expandedContent={
|
||||
<div className="space-y-2">
|
||||
{p.scenario_de && <div><span className="font-medium text-gray-700 dark:text-gray-300">Szenario:</span> {p.scenario_de}</div>}
|
||||
{p.trigger_de && <div><span className="font-medium text-gray-700 dark:text-gray-300">Ausloeser:</span> {p.trigger_de}</div>}
|
||||
{p.harm_de && <div><span className="font-medium text-gray-700 dark:text-gray-300">Schaden:</span> {p.harm_de}</div>}
|
||||
{p.affected_de && <div><span className="font-medium text-gray-700 dark:text-gray-300">Betroffene:</span> {p.affected_de}</div>}
|
||||
{p.zone_de && <div><span className="font-medium text-gray-700 dark:text-gray-300">Zone:</span> {p.zone_de}</div>}
|
||||
{p.generated_hazard_categories.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{p.generated_hazard_categories.map((c) => (
|
||||
<span key={c} className="px-1.5 py-0.5 rounded text-xs bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300">{c.replace(/_/g, ' ')}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{pageItems.length === 0 && (
|
||||
<tr><td colSpan={7} className="px-4 py-8 text-center text-sm text-gray-400">Keine Patterns gefunden</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import NormenTab, { type Norm } from './_components/NormenTab'
|
||||
import PatternsTab, { type HazardPattern } from './_components/PatternsTab'
|
||||
import MeasuresTab, { type ProtectiveMeasure } from './_components/MeasuresTab'
|
||||
|
||||
type TabId = 'normen' | 'patterns' | 'measures'
|
||||
|
||||
const TABS: { id: TabId; label: string }[] = [
|
||||
{ id: 'normen', label: 'Normen' },
|
||||
{ id: 'patterns', label: 'Patterns' },
|
||||
{ id: 'measures', label: 'Massnahmen' },
|
||||
]
|
||||
|
||||
export default function IACELibraryPage() {
|
||||
const [activeTab, setActiveTab] = useState<TabId>('normen')
|
||||
const [norms, setNorms] = useState<Norm[]>([])
|
||||
const [patterns, setPatterns] = useState<HazardPattern[]>([])
|
||||
const [measures, setMeasures] = useState<ProtectiveMeasure[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const [normsRes, patternsRes, measuresRes] = await Promise.all([
|
||||
fetch('/api/sdk/v1/iace/norms-library'),
|
||||
fetch('/api/sdk/v1/iace/hazard-patterns'),
|
||||
fetch('/api/sdk/v1/iace/protective-measures-library'),
|
||||
])
|
||||
if (normsRes.ok) {
|
||||
const data = await normsRes.json()
|
||||
setNorms(data.norms || [])
|
||||
}
|
||||
if (patternsRes.ok) {
|
||||
const data = await patternsRes.json()
|
||||
setPatterns(data.patterns || [])
|
||||
}
|
||||
if (measuresRes.ok) {
|
||||
const data = await measuresRes.json()
|
||||
setMeasures(data.protective_measures || [])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load IACE library data:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
load()
|
||||
}, [])
|
||||
|
||||
const counts: Record<TabId, number> = {
|
||||
normen: norms.length,
|
||||
patterns: patterns.length,
|
||||
measures: measures.length,
|
||||
}
|
||||
|
||||
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-7xl mx-auto">
|
||||
{/* Back link + Header */}
|
||||
<div>
|
||||
<Link
|
||||
href="/sdk/iace"
|
||||
className="inline-flex items-center gap-1 text-sm text-purple-600 hover:text-purple-800 mb-3"
|
||||
>
|
||||
<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>
|
||||
Zurueck zur IACE-Uebersicht
|
||||
</Link>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
Bibliothek
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Normen, Hazard Patterns und Schutzmassnahmen fuer die CE-Konformitaetsbewertung
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-gray-200 dark:border-gray-700">
|
||||
<nav className="flex gap-1" aria-label="Library tabs">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`px-4 py-2.5 text-sm font-medium rounded-t-lg transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 border-b-2 border-purple-600'
|
||||
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
<span className={`ml-1.5 inline-flex items-center px-1.5 py-0.5 rounded-full text-xs ${
|
||||
activeTab === tab.id
|
||||
? 'bg-purple-200 dark:bg-purple-800 text-purple-800 dark:text-purple-200'
|
||||
: 'bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400'
|
||||
}`}>
|
||||
{counts[tab.id]}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||
{activeTab === 'normen' && <NormenTab norms={norms} />}
|
||||
{activeTab === 'patterns' && <PatternsTab patterns={patterns} />}
|
||||
{activeTab === 'measures' && <MeasuresTab measures={measures} />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -227,7 +227,8 @@ export default function IACEDashboardPage() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Production Lines Quick Access */}
|
||||
{/* Quick Access Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Link
|
||||
href="/sdk/iace/lines"
|
||||
className="block bg-gradient-to-r from-purple-50 to-indigo-50 dark:from-purple-900/20 dark:to-indigo-900/20 rounded-xl border border-purple-200 dark:border-purple-800 p-6 hover:shadow-md hover:border-purple-300 transition-all group"
|
||||
@@ -254,6 +255,33 @@ export default function IACEDashboardPage() {
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/sdk/iace/library"
|
||||
className="block bg-gradient-to-r from-indigo-50 to-blue-50 dark:from-indigo-900/20 dark:to-blue-900/20 rounded-xl border border-indigo-200 dark:border-indigo-800 p-6 hover:shadow-md hover:border-indigo-300 transition-all group"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-indigo-100 dark:bg-indigo-900/40 rounded-xl flex items-center justify-center flex-shrink-0">
|
||||
<svg className="w-6 h-6 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Bibliothek
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Normen, Hazard Patterns und Schutzmassnahmen durchsuchen und filtern
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<svg className="w-5 h-5 text-indigo-400 group-hover:text-indigo-600 transition-colors flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Process Flow */}
|
||||
<ProcessFlow />
|
||||
|
||||
|
||||
Reference in New Issue
Block a user