From f4c9cea77079bc2c7c7009c47d32253c30eab3e0 Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Sun, 17 May 2026 13:50:45 +0200 Subject: [PATCH] =?UTF-8?q?feat(iace/mitigations):=20group=20measure=20row?= =?UTF-8?q?s=20by=20title,=20collapse=2021x=E2=86=921=20row?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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) --- .../sdk/iace/[projectId]/mitigations/page.tsx | 163 +++++++++++++----- 1 file changed, 121 insertions(+), 42 deletions(-) diff --git a/admin-compliance/app/sdk/iace/[projectId]/mitigations/page.tsx b/admin-compliance/app/sdk/iace/[projectId]/mitigations/page.tsx index b9f3d600..6ad056c3 100644 --- a/admin-compliance/app/sdk/iace/[projectId]/mitigations/page.tsx +++ b/admin-compliance/app/sdk/iace/[projectId]/mitigations/page.tsx @@ -50,6 +50,41 @@ export default function MitigationsPage() { const [selected, setSelected] = useState>(new Set()) const [batchAction, setBatchAction] = useState<'verify' | 'delete' | null>(null) const [expandedMeasure, setExpandedMeasure] = useState(null) + // Group-Expand: key = `${type}:${title}` so the same title in different + // reduction stages stays independently togglable. + const [expandedGroup, setExpandedGroup] = useState>(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() + 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] })) @@ -191,68 +226,112 @@ export default function MitigationsPage() { {items.length} - {/* Accordion Content — Table rows */} - {isExpanded && items.length > 0 && ( + {/* Accordion Content — grouped by measure title */} + {isExpanded && items.length > 0 && (() => { + const groups = groupByTitle(items) + const visibleGroups = groups.slice(0, (mitPages[type] || 1) * 50) + return (
{/* Table header */} -
+
selectAllInType(type)} className="accent-purple-600" title="Alle auswaehlen" />
Massnahme
-
Gefaehrdung
-
Status
+
Gefaehrdungen
+
Status (P · I · V)
- {/* Rows — paginated */} - {items.slice(0, (mitPages[type] || 1) * 50).map((m) => { - const isDetailOpen = expandedMeasure === m.id - const catMatch = (m.description || '').match(/Kategorie\s+(\S+)/) + {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] - const refs = measureNorms[(m.title || '').toLowerCase()] return ( -
-
setExpandedMeasure(isDetailOpen ? null : m.id)} - 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' : ''}`}> -
e.stopPropagation()}> - toggleSelect(m.id)} - className="accent-purple-600" /> -
-
- - - -
-
{m.title || ''}
- {!isDetailOpen && category &&
Kategorie: {category}
} +
+ {/* Group header row */} +
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' : ''}`}> +
e.stopPropagation()}> + { + 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`} /> +
+
+ + + +
+
{title}
+ {category &&
Kategorie: {category}
} +
+
+
{instances.length}
+
+ {counts.planned} + · + {counts.implemented} + · + {counts.verified}
-
- {(m.linked_hazard_names || []).join(', ') || '-'} -
-
- -
+ {/* Group children — one row per instance (hazard) */} + {isGroupOpen && ( +
+ {description && ( +

{description}

+ )} + {refs?.length > 0 && ( +

Normen: {refs.join(', ')}

+ )} + {instances.map((m) => { + const isDetailOpen = expandedMeasure === m.id + return ( +
+
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' : ''}`}> +
+
e.stopPropagation()}> + toggleSelect(m.id)} + className="accent-purple-600" /> +
+
+ {(m.linked_hazard_names || []).join(', ') || '— (keine Gefaehrdung verknuepft)'} +
+
+
+ {isDetailOpen && ( +
+ +
+ )} +
+ ) + })} +
+ )}
- {isDetailOpen && ( -
- {m.description &&

{m.description}

} - {category &&

Diese Massnahme gilt fuer alle Gefaehrdungen der Kategorie {category}.

} - {refs?.length > 0 &&

Normen: {refs.join(', ')}

} - -
- )} -
) })} - {items.length > (mitPages[type] || 1) * 50 && ( + {groups.length > visibleGroups.length && ( )}
- )} + ) + })()} {isExpanded && items.length === 0 && (