f4c9cea770
The "Maßnahmen" page in the Bremsscheibe project showed a flat list with
heavy redundancy — e.g. "Sicherheitszeichen nach ISO 7010" appeared on 21
separate rows, one per linked hazard. Same for "Gefahrenpiktogramme",
"Flucht- und Rettungswege" etc. The signal got lost in the noise.
This is a presentation-only regrouping. Each Hazard×Mitigation pair stays
a separate DB row with its own status, notes and edit history (option B
from the discussion: instances remain independently editable). The page
now collapses rows that share the same `m.title` into one group row.
Group row shows:
- title + ISO 12100 sub-category (if encoded in description)
- count of linked hazards on the right
- compact status distribution "P · I · V" (Planned/Implemented/Verified)
- shared checkbox that selects all instances in the group
Click expands the group and reveals the individual hazard×measure rows,
each with its own StatusBadge and detail-expand for MitigationHints.
State additions:
- expandedGroup: Set<string> with keys `${type}:${title}` so the same
title across different reduction stages stays independently togglable
- groupByTitle() helper trims the title, falls back to "(ohne Titel)"
- statusCounts() helper for the P·I·V breakdown
Pagination semantics swapped from 50 instances/page to 50 groups/page —
makes the list far easier to scan at the ~80-instance scale this project
exhibits.
LOC: 267 → 346 (well under the 500 hard cap).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
347 lines
17 KiB
TypeScript
347 lines
17 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect } from 'react'
|
|
import { useParams } from 'next/navigation'
|
|
import { REDUCTION_TYPES, Mitigation } from './_components/types'
|
|
import { HierarchyWarning } from './_components/HierarchyWarning'
|
|
import { MeasuresLibraryModal } from './_components/MeasuresLibraryModal'
|
|
import { SuggestMeasuresModal } from './_components/SuggestMeasuresModal'
|
|
import { MitigationForm } from './_components/MitigationForm'
|
|
import { StatusBadge } from './_components/StatusBadge'
|
|
import { MitigationHints } from './_components/MitigationHints'
|
|
import { ProtectiveMeasure } from './_components/types'
|
|
import { useMitigations } from './_hooks/useMitigations'
|
|
|
|
export default function MitigationsPage() {
|
|
const params = useParams()
|
|
const projectId = params.projectId as string
|
|
|
|
const {
|
|
hazards, loading, hierarchyWarning, setHierarchyWarning,
|
|
measures, byType,
|
|
fetchMeasuresLibrary, handleSubmit, handleAddSuggestedMeasure, handleVerify, handleDelete,
|
|
} = useMitigations(projectId)
|
|
|
|
const [measureNorms, setMeasureNorms] = useState<Record<string, string[]>>({})
|
|
|
|
useEffect(() => {
|
|
fetch('/api/sdk/v1/iace/protective-measures-library')
|
|
.then(r => r.ok ? r.json() : null)
|
|
.then(json => {
|
|
if (!json?.protective_measures) return
|
|
const map: Record<string, string[]> = {}
|
|
for (const m of json.protective_measures) {
|
|
if (m.norm_references?.length > 0) {
|
|
map[(m.name || '').toLowerCase()] = m.norm_references
|
|
}
|
|
}
|
|
setMeasureNorms(map)
|
|
})
|
|
.catch(() => {})
|
|
}, [])
|
|
|
|
const [showForm, setShowForm] = useState(false)
|
|
const [preselectedType, setPreselectedType] = useState<'design' | 'protection' | 'information' | undefined>()
|
|
const [showLibrary, setShowLibrary] = useState(false)
|
|
const [libraryFilter, setLibraryFilter] = useState<string | undefined>()
|
|
const [showSuggest, setShowSuggest] = useState(false)
|
|
const [expanded, setExpanded] = useState<Record<string, boolean>>({ design: true, protection: true, information: true })
|
|
const [mitPages, setMitPages] = useState<Record<string, number>>({ design: 1, protection: 1, information: 1 })
|
|
const [selected, setSelected] = useState<Set<string>>(new Set())
|
|
const [batchAction, setBatchAction] = useState<'verify' | 'delete' | null>(null)
|
|
const [expandedMeasure, setExpandedMeasure] = useState<string | null>(null)
|
|
// Group-Expand: key = `${type}:${title}` so the same title in different
|
|
// reduction stages stays independently togglable.
|
|
const [expandedGroup, setExpandedGroup] = useState<Set<string>>(new Set())
|
|
|
|
function toggleGroup(key: string) {
|
|
setExpandedGroup((prev) => {
|
|
const next = new Set(prev)
|
|
if (next.has(key)) next.delete(key); else next.add(key)
|
|
return next
|
|
})
|
|
}
|
|
|
|
// Mitigations sharing the same title (e.g. "Sicherheitszeichen nach ISO 7010"
|
|
// applied to 21 hazards) collapse into a single group row. Each instance
|
|
// keeps its own DB id, status and notes — the grouping is presentation-only.
|
|
function groupByTitle(items: Mitigation[]): Array<{ title: string; instances: Mitigation[] }> {
|
|
const map = new Map<string, Mitigation[]>()
|
|
for (const m of items) {
|
|
const key = (m.title || '').trim() || '(ohne Titel)'
|
|
const arr = map.get(key)
|
|
if (arr) arr.push(m); else map.set(key, [m])
|
|
}
|
|
return Array.from(map.entries()).map(([title, instances]) => ({ title, instances }))
|
|
}
|
|
|
|
// Compact status distribution: returns counts for the three known states.
|
|
function statusCounts(instances: Mitigation[]) {
|
|
const c = { planned: 0, implemented: 0, verified: 0 }
|
|
for (const m of instances) {
|
|
if (m.status === 'planned') c.planned++
|
|
else if (m.status === 'implemented') c.implemented++
|
|
else if (m.status === 'verified') c.verified++
|
|
}
|
|
return c
|
|
}
|
|
|
|
function toggleSection(type: string) {
|
|
setExpanded((prev) => ({ ...prev, [type]: !prev[type] }))
|
|
}
|
|
|
|
function toggleSelect(id: string) {
|
|
setSelected((prev) => {
|
|
const next = new Set(prev)
|
|
if (next.has(id)) next.delete(id); else next.add(id)
|
|
return next
|
|
})
|
|
}
|
|
|
|
function selectAllInType(type: string) {
|
|
const items = byType[type as keyof typeof byType]
|
|
setSelected((prev) => {
|
|
const next = new Set(prev)
|
|
const allSelected = items.every((m) => next.has(m.id))
|
|
if (allSelected) { items.forEach((m) => next.delete(m.id)) }
|
|
else { items.forEach((m) => next.add(m.id)) }
|
|
return next
|
|
})
|
|
}
|
|
|
|
async function handleBatchVerify() {
|
|
setBatchAction('verify')
|
|
for (const id of selected) { await handleVerify(id) }
|
|
setSelected(new Set())
|
|
setBatchAction(null)
|
|
}
|
|
|
|
async function handleBatchDelete() {
|
|
if (!confirm(`${selected.size} Massnahmen wirklich loeschen?`)) return
|
|
setBatchAction('delete')
|
|
for (const id of selected) { await handleDelete(id) }
|
|
setSelected(new Set())
|
|
setBatchAction(null)
|
|
}
|
|
|
|
function handleOpenLibrary(type?: string) {
|
|
setLibraryFilter(type)
|
|
fetchMeasuresLibrary(type)
|
|
setShowLibrary(true)
|
|
}
|
|
|
|
function handleSelectMeasure(measure: ProtectiveMeasure) {
|
|
setShowLibrary(false)
|
|
setShowForm(true)
|
|
setPreselectedType(measure.reduction_type as 'design' | 'protection' | 'information')
|
|
}
|
|
|
|
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>
|
|
)
|
|
}
|
|
|
|
const totalMeasures = byType.design.length + byType.protection.length + byType.information.length
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Massnahmen</h1>
|
|
<p className="mt-1 text-sm text-gray-500">
|
|
{totalMeasures} Massnahmen nach 3-Stufen-Verfahren: Design ({byType.design.length}) → Schutz ({byType.protection.length}) → Information ({byType.information.length})
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{selected.size > 0 && (
|
|
<>
|
|
<span className="text-xs text-gray-500">{selected.size} ausgewaehlt</span>
|
|
<button onClick={handleBatchVerify} disabled={batchAction !== null}
|
|
className="px-3 py-1.5 text-xs bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50">
|
|
{batchAction === 'verify' ? 'Verifiziere...' : 'Verifizieren'}
|
|
</button>
|
|
<button onClick={handleBatchDelete} disabled={batchAction !== null}
|
|
className="px-3 py-1.5 text-xs bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50">
|
|
Loeschen
|
|
</button>
|
|
<button onClick={() => setSelected(new Set())} className="px-2 py-1.5 text-xs text-gray-500 hover:text-gray-700">
|
|
Abbrechen
|
|
</button>
|
|
</>
|
|
)}
|
|
{selected.size === 0 && (
|
|
<>
|
|
<button onClick={() => setShowSuggest(true)}
|
|
className="px-3 py-1.5 text-xs border border-green-300 text-green-700 rounded-lg hover:bg-green-50">
|
|
Vorschlaege
|
|
</button>
|
|
<button onClick={() => handleOpenLibrary()}
|
|
className="px-3 py-1.5 text-xs border border-purple-300 text-purple-700 rounded-lg hover:bg-purple-50">
|
|
Bibliothek
|
|
</button>
|
|
<button onClick={() => { setPreselectedType(undefined); setShowForm(true) }}
|
|
className="px-3 py-1.5 text-xs bg-purple-600 text-white rounded-lg hover:bg-purple-700">
|
|
+ Hinzufuegen
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{hierarchyWarning && <HierarchyWarning onDismiss={() => setHierarchyWarning(false)} />}
|
|
|
|
{showForm && (
|
|
<MitigationForm
|
|
onSubmit={async (data) => { const ok = await handleSubmit(data); if (ok) setShowForm(false) }}
|
|
onCancel={() => setShowForm(false)} hazards={hazards} preselectedType={preselectedType} onOpenLibrary={handleOpenLibrary}
|
|
/>
|
|
)}
|
|
{showLibrary && <MeasuresLibraryModal measures={measures} onSelect={handleSelectMeasure} onClose={() => setShowLibrary(false)} filterType={libraryFilter} />}
|
|
{showSuggest && <SuggestMeasuresModal hazards={hazards} projectId={projectId} onAddMeasure={handleAddSuggestedMeasure} onClose={() => setShowSuggest(false)} />}
|
|
|
|
{/* 3-Step Accordions */}
|
|
{(['design', 'protection', 'information'] as const).map((type) => {
|
|
const config = REDUCTION_TYPES[type]
|
|
const items = byType[type]
|
|
const isExpanded = expanded[type]
|
|
const allSelected = items.length > 0 && items.every((m) => selected.has(m.id))
|
|
|
|
return (
|
|
<div key={type} className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
|
{/* Accordion Header */}
|
|
<button onClick={() => toggleSection(type)}
|
|
className={`w-full flex items-center gap-3 px-4 py-3 text-left transition-colors ${config.headerColor}`}>
|
|
<svg className={`w-4 h-4 transition-transform ${isExpanded ? '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>
|
|
{config.icon}
|
|
<div className="flex-1">
|
|
<span className="text-sm font-semibold">{config.label}</span>
|
|
<span className="ml-2 text-xs opacity-75">{config.description}</span>
|
|
</div>
|
|
<span className="text-sm font-bold">{items.length}</span>
|
|
</button>
|
|
|
|
{/* Accordion Content — grouped by measure title */}
|
|
{isExpanded && items.length > 0 && (() => {
|
|
const groups = groupByTitle(items)
|
|
const visibleGroups = groups.slice(0, (mitPages[type] || 1) * 50)
|
|
return (
|
|
<div className="border-t border-gray-100 dark:border-gray-700">
|
|
{/* Table header */}
|
|
<div className="grid grid-cols-[24px_2fr_140px_120px] gap-2 px-4 py-2 bg-gray-50 dark:bg-gray-750 text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
<div>
|
|
<input type="checkbox" checked={allSelected} onChange={() => selectAllInType(type)}
|
|
className="accent-purple-600" title="Alle auswaehlen" />
|
|
</div>
|
|
<div>Massnahme</div>
|
|
<div className="text-right pr-2">Gefaehrdungen</div>
|
|
<div>Status (P · I · V)</div>
|
|
</div>
|
|
{visibleGroups.map(({ title, instances }) => {
|
|
const groupKey = `${type}:${title}`
|
|
const isGroupOpen = expandedGroup.has(groupKey)
|
|
const allInGroupSelected = instances.length > 0 && instances.every((m) => selected.has(m.id))
|
|
const counts = statusCounts(instances)
|
|
const refs = measureNorms[title.toLowerCase()]
|
|
const first = instances[0]
|
|
const description = first?.description || ''
|
|
const catMatch = description.match(/Kategorie\s+(\S+)/)
|
|
const category = catMatch?.[1]
|
|
return (
|
|
<div key={groupKey}>
|
|
{/* Group header row */}
|
|
<div onClick={() => toggleGroup(groupKey)}
|
|
className={`grid grid-cols-[24px_2fr_140px_120px] gap-2 px-4 py-2 border-t border-gray-50 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors cursor-pointer ${allInGroupSelected ? 'bg-purple-50 dark:bg-purple-900/10' : ''}`}>
|
|
<div className="pt-0.5" onClick={(e) => e.stopPropagation()}>
|
|
<input type="checkbox" checked={allInGroupSelected} onChange={() => {
|
|
setSelected((prev) => {
|
|
const next = new Set(prev)
|
|
if (allInGroupSelected) instances.forEach((m) => next.delete(m.id))
|
|
else instances.forEach((m) => next.add(m.id))
|
|
return next
|
|
})
|
|
}} className="accent-purple-600" title={`Alle ${instances.length} Instanzen auswaehlen`} />
|
|
</div>
|
|
<div className="min-w-0 flex items-start gap-1">
|
|
<svg className={`w-3 h-3 mt-1 shrink-0 text-gray-400 transition-transform ${isGroupOpen ? '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>
|
|
<div>
|
|
<div className="text-sm text-gray-900 dark:text-white">{title}</div>
|
|
{category && <div className="text-[10px] text-gray-400 mt-0.5">Kategorie: {category}</div>}
|
|
</div>
|
|
</div>
|
|
<div className="text-xs text-gray-500 text-right pr-2">{instances.length}</div>
|
|
<div className="text-xs flex items-center gap-1.5 font-mono">
|
|
<span className="text-gray-500" title={`${counts.planned} geplant`}>{counts.planned}</span>
|
|
<span className="text-gray-300">·</span>
|
|
<span className="text-blue-600" title={`${counts.implemented} umgesetzt`}>{counts.implemented}</span>
|
|
<span className="text-gray-300">·</span>
|
|
<span className="text-green-600" title={`${counts.verified} verifiziert`}>{counts.verified}</span>
|
|
</div>
|
|
</div>
|
|
{/* Group children — one row per instance (hazard) */}
|
|
{isGroupOpen && (
|
|
<div className="bg-gray-50/40 dark:bg-gray-900/20 border-t border-gray-100 dark:border-gray-700">
|
|
{description && (
|
|
<p className="px-12 pt-2 pb-1 text-[11px] text-gray-500 dark:text-gray-400 italic">{description}</p>
|
|
)}
|
|
{refs?.length > 0 && (
|
|
<p className="px-12 pb-2 text-[11px] text-blue-500">Normen: {refs.join(', ')}</p>
|
|
)}
|
|
{instances.map((m) => {
|
|
const isDetailOpen = expandedMeasure === m.id
|
|
return (
|
|
<div key={m.id}>
|
|
<div onClick={() => setExpandedMeasure(isDetailOpen ? null : m.id)}
|
|
className={`grid grid-cols-[40px_24px_2fr_140px] gap-2 px-4 py-1.5 border-t border-gray-100 dark:border-gray-700 hover:bg-white dark:hover:bg-gray-800 transition-colors cursor-pointer ${selected.has(m.id) ? 'bg-purple-50 dark:bg-purple-900/10' : ''}`}>
|
|
<div />
|
|
<div className="pt-0.5" onClick={(e) => e.stopPropagation()}>
|
|
<input type="checkbox" checked={selected.has(m.id)} onChange={() => toggleSelect(m.id)}
|
|
className="accent-purple-600" />
|
|
</div>
|
|
<div className="text-xs text-gray-600 dark:text-gray-300 min-w-0">
|
|
{(m.linked_hazard_names || []).join(', ') || '— (keine Gefaehrdung verknuepft)'}
|
|
</div>
|
|
<div><StatusBadge status={m.status} /></div>
|
|
</div>
|
|
{isDetailOpen && (
|
|
<div className="px-12 py-2 bg-white dark:bg-gray-800 border-t border-gray-100 dark:border-gray-700 text-xs space-y-1">
|
|
<MitigationHints projectId={projectId} mitigationId={m.id} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
{groups.length > visibleGroups.length && (
|
|
<button onClick={() => setMitPages(prev => ({ ...prev, [type]: (prev[type] || 1) + 1 }))}
|
|
className="w-full py-2 text-xs text-purple-600 hover:bg-purple-50 border-t border-gray-100 transition-colors">
|
|
Weitere {Math.min(50, groups.length - visibleGroups.length)} von {groups.length} Maßnahmen laden...
|
|
</button>
|
|
)}
|
|
</div>
|
|
)
|
|
})()}
|
|
|
|
{isExpanded && items.length === 0 && (
|
|
<div className="px-4 py-6 text-center text-sm text-gray-400 border-t border-gray-100">
|
|
Keine Massnahmen in dieser Stufe
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)
|
|
}
|