From a708d139abf96046d5db2c6a6f67162a72722c54 Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Fri, 8 May 2026 00:09:31 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20IACE=20Bibliotheks-Browser=20=E2=80=94?= =?UTF-8?q?=20751=20Normen,=201000=20Patterns,=20200=20Massnahmen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../iace/library/_components/LibraryTable.tsx | 128 ++++++++++++++++ .../iace/library/_components/MeasuresTab.tsx | 129 ++++++++++++++++ .../iace/library/_components/NormenTab.tsx | 133 +++++++++++++++++ .../iace/library/_components/PatternsTab.tsx | 141 ++++++++++++++++++ .../app/sdk/iace/library/page.tsx | 122 +++++++++++++++ admin-compliance/app/sdk/iace/page.tsx | 76 +++++++--- 6 files changed, 705 insertions(+), 24 deletions(-) create mode 100644 admin-compliance/app/sdk/iace/library/_components/LibraryTable.tsx create mode 100644 admin-compliance/app/sdk/iace/library/_components/MeasuresTab.tsx create mode 100644 admin-compliance/app/sdk/iace/library/_components/NormenTab.tsx create mode 100644 admin-compliance/app/sdk/iace/library/_components/PatternsTab.tsx create mode 100644 admin-compliance/app/sdk/iace/library/page.tsx diff --git a/admin-compliance/app/sdk/iace/library/_components/LibraryTable.tsx b/admin-compliance/app/sdk/iace/library/_components/LibraryTable.tsx new file mode 100644 index 0000000..3f06e2c --- /dev/null +++ b/admin-compliance/app/sdk/iace/library/_components/LibraryTable.tsx @@ -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 ( +
+ + + Seite {page} / {totalPages} + + +
+ ) +} + +// ---------- Search ---------- +interface SearchInputProps { + value: string + onChange: (v: string) => void + placeholder?: string +} + +export function SearchInput({ value, onChange, placeholder }: SearchInputProps) { + return ( +
+ + + + 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" + /> +
+ ) +} + +// ---------- 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 ( + + ) +} + +// ---------- 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 ( + <> + 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) => ( + + {cell} + + ))} + + + + + + + {open && ( + + + {expandedContent} + + + )} + + ) +} + +// ---------- External Link Icon ---------- +export function ExternalLinkIcon() { + return ( + + + + ) +} diff --git a/admin-compliance/app/sdk/iace/library/_components/MeasuresTab.tsx b/admin-compliance/app/sdk/iace/library/_components/MeasuresTab.tsx new file mode 100644 index 0000000..ce141cd --- /dev/null +++ b/admin-compliance/app/sdk/iace/library/_components/MeasuresTab.tsx @@ -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 | 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 ( +
+
+
+ +
+ + + {measures.length} Massnahmen{filtered.length !== measures.length && ` (${filtered.length} gefiltert)`} + +
+ +
+ + + + {['ID', 'Name', 'Typ', 'Subtyp', 'Kategorie', 'Normen'].map((h) => ( + + ))} + + + + {pageItems.map((m) => ( + {m.id.slice(0, 8)}, + {m.name}, + {m.reduction_type}, + {m.sub_type || '-'}, + {m.hazard_category?.replace(/_/g, ' ') || '-'}, + {m.norm_references.length > 0 ? m.norm_references.slice(0, 2).join(', ') : '-'}{m.norm_references.length > 2 ? ` +${m.norm_references.length - 2}` : ''}, + ]} + expandedContent={ +
+ {m.description &&
Beschreibung: {m.description}
} + {m.examples.length > 0 && ( +
+ Beispiele: +
    + {m.examples.map((ex, i) =>
  • {ex}
  • )} +
+
+ )} + {m.norm_references.length > 0 && ( +
+ {m.norm_references.map((nr) => ( + {nr} + ))} +
+ )} +
+ } + /> + ))} + {pageItems.length === 0 && ( +
+ )} + +
{h} +
Keine Massnahmen gefunden
+
+ + +
+ ) +} diff --git a/admin-compliance/app/sdk/iace/library/_components/NormenTab.tsx b/admin-compliance/app/sdk/iace/library/_components/NormenTab.tsx new file mode 100644 index 0000000..9c6e460 --- /dev/null +++ b/admin-compliance/app/sdk/iace/library/_components/NormenTab.tsx @@ -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 | 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 ( +
+
+
+ +
+ + + + {norms.length} Normen{filtered.length !== norms.length && ` (${filtered.length} gefiltert)`} + +
+ +
+ + + + {['Nummer', 'Titel', 'Typ', 'Abschnitte', 'Pflicht', 'Beuth'].map((h) => ( + + ))} + + + + {pageItems.map((n) => ( + {n.number}, + {n.title_de}, + {n.norm_type}, + {n.relevant_sections.length > 0 ? n.relevant_sections.slice(0, 3).join(', ') : '-'}{n.relevant_sections.length > 3 ? ` +${n.relevant_sections.length - 3}` : ''}, + n.mandatory + ? Ja + : Nein, + n.beuth_url + ? e.stopPropagation()} className="text-purple-600 hover:text-purple-800 text-xs">Link + : -, + ]} + expandedContent={ +
+
Scope: {n.scope_de || '-'}
+ {n.relevant_sections.length > 0 && ( +
Abschnitte: {n.relevant_sections.join(', ')}
+ )} + {n.machine_types.length > 0 && ( +
Maschinentypen: {n.machine_types.join(', ')}
+ )} + {n.tags.length > 0 && ( +
+ {n.tags.map((t) => {t})} +
+ )} +
+ } + /> + ))} + {pageItems.length === 0 && ( +
+ )} + +
{h} +
Keine Normen gefunden
+
+ + +
+ ) +} diff --git a/admin-compliance/app/sdk/iace/library/_components/PatternsTab.tsx b/admin-compliance/app/sdk/iace/library/_components/PatternsTab.tsx new file mode 100644 index 0000000..322def2 --- /dev/null +++ b/admin-compliance/app/sdk/iace/library/_components/PatternsTab.tsx @@ -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 | 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() + 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 ( +
+
+
+ +
+ + + + {patterns.length} Patterns{filtered.length !== patterns.length && ` (${filtered.length} gefiltert)`} + +
+ +
+ + + + {['ID', 'Name', 'Kategorie', 'Prioritaet', 'Schaden', 'Tags'].map((h) => ( + + ))} + + + + {pageItems.map((p) => ( + {p.id.slice(0, 8)}, + {p.name_de}, + {p.generated_hazard_categories[0]?.replace(/_/g, ' ') || '-'}, + {p.priority}, + {p.harm_de || '-'}, + {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}` : ''}, + ]} + expandedContent={ +
+ {p.scenario_de &&
Szenario: {p.scenario_de}
} + {p.trigger_de &&
Ausloeser: {p.trigger_de}
} + {p.harm_de &&
Schaden: {p.harm_de}
} + {p.affected_de &&
Betroffene: {p.affected_de}
} + {p.zone_de &&
Zone: {p.zone_de}
} + {p.generated_hazard_categories.length > 0 && ( +
+ {p.generated_hazard_categories.map((c) => ( + {c.replace(/_/g, ' ')} + ))} +
+ )} +
+ } + /> + ))} + {pageItems.length === 0 && ( +
+ )} + +
{h} +
Keine Patterns gefunden
+
+ + +
+ ) +} diff --git a/admin-compliance/app/sdk/iace/library/page.tsx b/admin-compliance/app/sdk/iace/library/page.tsx new file mode 100644 index 0000000..8434746 --- /dev/null +++ b/admin-compliance/app/sdk/iace/library/page.tsx @@ -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('normen') + const [norms, setNorms] = useState([]) + const [patterns, setPatterns] = useState([]) + const [measures, setMeasures] = useState([]) + 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 = { + normen: norms.length, + patterns: patterns.length, + measures: measures.length, + } + + if (loading) { + return ( +
+
+
+ ) + } + + return ( +
+ {/* Back link + Header */} +
+ + + + + Zurueck zur IACE-Uebersicht + +

+ Bibliothek +

+

+ Normen, Hazard Patterns und Schutzmassnahmen fuer die CE-Konformitaetsbewertung +

+
+ + {/* Tabs */} +
+ +
+ + {/* Tab Content */} +
+ {activeTab === 'normen' && } + {activeTab === 'patterns' && } + {activeTab === 'measures' && } +
+
+ ) +} diff --git a/admin-compliance/app/sdk/iace/page.tsx b/admin-compliance/app/sdk/iace/page.tsx index 2f56c6b..b792e0b 100644 --- a/admin-compliance/app/sdk/iace/page.tsx +++ b/admin-compliance/app/sdk/iace/page.tsx @@ -227,32 +227,60 @@ export default function IACEDashboardPage() {
- {/* Production Lines Quick Access */} - -
-
-
- - - -
-
-

- Produktionslinien -

-

- Verkettete Fertigungsstrassen mit aggregierter Risikoansicht und animiertem Stationsfluss -

+ {/* Quick Access Cards */} +
+ +
+
+
+ + + +
+
+

+ Produktionslinien +

+

+ Verkettete Fertigungsstrassen mit aggregierter Risikoansicht und animiertem Stationsfluss +

+
+ + +
- - - -
- + + + +
+
+
+ + + +
+
+

+ Bibliothek +

+

+ Normen, Hazard Patterns und Schutzmassnahmen durchsuchen und filtern +

+
+
+ + + +
+ +
{/* Process Flow */}