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:
Benjamin Admin
2026-05-08 00:09:31 +02:00
parent a3a83e5677
commit a708d139ab
6 changed files with 705 additions and 24 deletions
@@ -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"
>
&lsaquo; 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 &rsaquo;
</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>
)
}
+29 -1
View File
@@ -227,7 +227,8 @@ export default function IACEDashboardPage() {
</button> </button>
</div> </div>
{/* Production Lines Quick Access */} {/* Quick Access Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Link <Link
href="/sdk/iace/lines" 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" 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> </div>
</Link> </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 */} {/* Process Flow */}
<ProcessFlow /> <ProcessFlow />