feat(iace/verification): derived view on relevant mitigations + 2 actions

Task #21. The verification page used to manage a separate VerificationItem
entity that the expert had to populate by hand — disjoint from the actual
mitigations list. With the is_relevant flag from migration 029, the
verification step has a natural definition: confirm completion for every
mitigation the expert flagged as relevant for this project.

Page is now a derived view on useMitigations(): filter is_relevant=true,
group by title (same dedupe as Massnahmen page), expose two actions per
hazard×mitigation row:

  1. "Kundenstandard" — already implemented at the customer's site, no
     evidence file required. Sets is_customer_standard=true and
     status='verified'.

  2. "Verifizieren…" — opens a modal asking for a textual evidence
     reference (Prüfprotokoll-Nr, audit reference, etc.). Calls the
     existing POST /mitigations/:mid/verify with verification_result.
     File upload is deferred to phase 2 once an object-storage backend
     is in place — the modal explains this.

When a row is verified, a "Zurücksetzen" link reverts status to
'implemented' for accidental confirmations.

Header counters: total relevant / open / verified / Kundenstandard.

Maßnahmen-page polish (same commit):
  - "Lösch."-column header removed — the trash icon is self-explanatory
  - groupByTitle now additionally deduplicates by hazard_id within a
    group (engine occasionally emits duplicate (name, hazard_id) pairs
    when Reinit is clicked twice; a follow-up migration 030 will add
    a UNIQUE constraint to prevent these upstream)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-05-17 14:49:56 +02:00
parent 8f4f59f0e3
commit 3faa312b31
2 changed files with 225 additions and 142 deletions
@@ -64,6 +64,14 @@ export default function MitigationsPage() {
// 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.
//
// Within a group we additionally deduplicate by hazard_id: the engine
// sometimes emits the same (name, hazard_id) pair twice when "Neu
// initialisieren" is clicked repeatedly. We pick the row that already
// carries user state (is_relevant=true preferred, then newest created_at)
// so the expert's decisions are not lost. The DB still holds both rows;
// a separate migration adds a UNIQUE(hazard_id, name) constraint to
// prevent the duplicates upstream.
function groupByTitle(items: Mitigation[]): Array<{ title: string; instances: Mitigation[] }> {
const map = new Map<string, Mitigation[]>()
for (const m of items) {
@@ -71,7 +79,18 @@ export default function MitigationsPage() {
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 }))
return Array.from(map.entries()).map(([title, instances]) => {
const byHazard = new Map<string, Mitigation>()
for (const m of instances) {
const hid = (m.linked_hazard_ids || []).join('|') || m.id
const prev = byHazard.get(hid)
if (!prev) { byHazard.set(hid, m); continue }
// Tie-break: prefer is_relevant=true, then newest created_at
const score = (x: Mitigation) => (x.is_relevant ? 2 : 0) + (x.created_at > (prev.created_at || '') ? 1 : 0)
if (score(m) > score(prev)) byHazard.set(hid, m)
}
return { title, instances: Array.from(byHazard.values()) }
})
}
// Compact status distribution: returns counts for the three known states.
@@ -187,7 +206,7 @@ export default function MitigationsPage() {
{/* Table header */}
<div className="grid grid-cols-[36px_36px_2fr_120px_110px] gap-2 px-4 py-2 bg-gray-50 dark:bg-gray-750 text-xs font-medium text-gray-500 uppercase tracking-wider">
<div title="Relevant fuer dieses Projekt">Relev.</div>
<div title="Loeschen">Lösch.</div>
<div />
<div>Massnahme</div>
<div className="text-right pr-2">Gefährdungen</div>
<div>Status (P · I · V)</div>