feat(iace/mitigations): group measure rows by title, collapse 21x→1 row

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>
This commit is contained in:
Benjamin Admin
2026-05-17 13:50:45 +02:00
parent 6ed30dae5b
commit f4c9cea770
@@ -50,6 +50,41 @@ export default function MitigationsPage() {
const [selected, setSelected] = useState<Set<string>>(new Set()) const [selected, setSelected] = useState<Set<string>>(new Set())
const [batchAction, setBatchAction] = useState<'verify' | 'delete' | null>(null) const [batchAction, setBatchAction] = useState<'verify' | 'delete' | null>(null)
const [expandedMeasure, setExpandedMeasure] = useState<string | 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) { function toggleSection(type: string) {
setExpanded((prev) => ({ ...prev, [type]: !prev[type] })) setExpanded((prev) => ({ ...prev, [type]: !prev[type] }))
@@ -191,68 +226,112 @@ export default function MitigationsPage() {
<span className="text-sm font-bold">{items.length}</span> <span className="text-sm font-bold">{items.length}</span>
</button> </button>
{/* Accordion Content — Table rows */} {/* Accordion Content — grouped by measure title */}
{isExpanded && items.length > 0 && ( {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"> <div className="border-t border-gray-100 dark:border-gray-700">
{/* Table header */} {/* Table header */}
<div className="grid grid-cols-[24px_2fr_1fr_80px] gap-2 px-4 py-2 bg-gray-50 dark:bg-gray-750 text-xs font-medium text-gray-500 uppercase tracking-wider"> <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> <div>
<input type="checkbox" checked={allSelected} onChange={() => selectAllInType(type)} <input type="checkbox" checked={allSelected} onChange={() => selectAllInType(type)}
className="accent-purple-600" title="Alle auswaehlen" /> className="accent-purple-600" title="Alle auswaehlen" />
</div> </div>
<div>Massnahme</div> <div>Massnahme</div>
<div>Gefaehrdung</div> <div className="text-right pr-2">Gefaehrdungen</div>
<div>Status</div> <div>Status (P · I · V)</div>
</div> </div>
{/* Rows — paginated */} {visibleGroups.map(({ title, instances }) => {
{items.slice(0, (mitPages[type] || 1) * 50).map((m) => { const groupKey = `${type}:${title}`
const isDetailOpen = expandedMeasure === m.id const isGroupOpen = expandedGroup.has(groupKey)
const catMatch = (m.description || '').match(/Kategorie\s+(\S+)/) 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] const category = catMatch?.[1]
const refs = measureNorms[(m.title || '').toLowerCase()]
return ( return (
<div key={m.id}> <div key={groupKey}>
<div onClick={() => setExpandedMeasure(isDetailOpen ? null : m.id)} {/* Group header row */}
className={`grid grid-cols-[24px_2fr_1fr_80px] 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 ${selected.has(m.id) ? 'bg-purple-50 dark:bg-purple-900/10' : ''}`}> <div onClick={() => toggleGroup(groupKey)}
<div className="pt-0.5" onClick={(e) => e.stopPropagation()}> 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' : ''}`}>
<input type="checkbox" checked={selected.has(m.id)} onChange={() => toggleSelect(m.id)} <div className="pt-0.5" onClick={(e) => e.stopPropagation()}>
className="accent-purple-600" /> <input type="checkbox" checked={allInGroupSelected} onChange={() => {
</div> setSelected((prev) => {
<div className="min-w-0 flex items-start gap-1"> const next = new Set(prev)
<svg className={`w-3 h-3 mt-1 shrink-0 text-gray-400 transition-transform ${isDetailOpen ? 'rotate-90' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor"> if (allInGroupSelected) instances.forEach((m) => next.delete(m.id))
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /> else instances.forEach((m) => next.add(m.id))
</svg> return next
<div> })
<div className="text-sm text-gray-900 dark:text-white">{m.title || ''}</div> }} className="accent-purple-600" title={`Alle ${instances.length} Instanzen auswaehlen`} />
{!isDetailOpen && category && <div className="text-[10px] text-gray-400 mt-0.5">Kategorie: {category}</div>} </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>
</div> </div>
<div className="text-xs text-gray-500"> {/* Group children — one row per instance (hazard) */}
{(m.linked_hazard_names || []).join(', ') || '-'} {isGroupOpen && (
</div> <div className="bg-gray-50/40 dark:bg-gray-900/20 border-t border-gray-100 dark:border-gray-700">
<div> {description && (
<StatusBadge status={m.status} /> <p className="px-12 pt-2 pb-1 text-[11px] text-gray-500 dark:text-gray-400 italic">{description}</p>
</div> )}
{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> </div>
{isDetailOpen && (
<div className="px-12 py-3 bg-gray-50 dark:bg-gray-750 border-t border-gray-100 dark:border-gray-700 text-xs space-y-1">
{m.description && <p className="text-gray-600 dark:text-gray-300">{m.description}</p>}
{category && <p className="text-purple-600">Diese Massnahme gilt fuer alle Gefaehrdungen der Kategorie <strong>{category}</strong>.</p>}
{refs?.length > 0 && <p className="text-blue-500">Normen: {refs.join(', ')}</p>}
<MitigationHints projectId={projectId} mitigationId={m.id} />
</div>
)}
</div>
) )
})} })}
{items.length > (mitPages[type] || 1) * 50 && ( {groups.length > visibleGroups.length && (
<button onClick={() => setMitPages(prev => ({ ...prev, [type]: (prev[type] || 1) + 1 }))} <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"> 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, items.length - (mitPages[type] || 1) * 50)} von {items.length} laden... Weitere {Math.min(50, groups.length - visibleGroups.length)} von {groups.length} Maßnahmen laden...
</button> </button>
)} )}
</div> </div>
)} )
})()}
{isExpanded && items.length === 0 && ( {isExpanded && items.length === 0 && (
<div className="px-4 py-6 text-center text-sm text-gray-400 border-t border-gray-100"> <div className="px-4 py-6 text-center text-sm text-gray-400 border-t border-gray-100">