feat(iace/mitigations): is_relevant + is_customer_standard flags

[migration-approved]

Expert-driven workflow refinement on the Massnahmen page. The engine seeds
~80 mitigations per project, but for a concrete customer site most need a
relevance decision before they're meaningful in verification:

  status: 'planned' | 'implemented' | 'verified'   (existing — verification track)
  is_relevant          bool   (new)                (does this apply to *this* site?)
  is_customer_standard bool   (new)                (already in place at customer — no evidence)

Decision flow on the Mitigations tab:
  Engine-seeded → is_relevant=false (Default, waiting for expert)
  Expert checks "Relevant" → is_relevant=true → surfaces in verification
  Expert clicks trash       → DELETE (banner warns: do not click Reinit
                                       afterwards or seeds come back)
  In verification, customer_standard=true bypasses evidence upload

is_customer_standard implies is_relevant (DB CHECK constraint).

Migration 029_iace_mitigation_relevance.sql:
  ALTER TABLE iace_mitigations ADD COLUMN is_relevant ..., is_customer_standard ...
  + CHECK constraint + partial index on is_relevant for the verification
    page's filter.

Backend (Go):
  - Mitigation struct gains two bool fields
  - CreateMitigation: defaults to false/false (engine-seeded mitigations
    start unbewertet)
  - UpdateMitigation: new case clauses for both keys; setting
    is_customer_standard=true auto-flips is_relevant=true to satisfy
    the CHECK constraint
  - All three SELECT statements (ListMitigations, ListMitigationsByProject,
    getMitigation) extended with the two new columns

Frontend:
  - Maßnahmen-page columns: [Relev. ☑] [Lösch. 🗑] Title | #Hazards | P·I·V
  - Group-header checkbox shows tri-state (indeterminate when partial),
    flips all instances in the group at once
  - Banner above the table: "Markiere jede Maßnahme als Relevant oder
    lösche sie. Nach Löschen kein Neu initialisieren mehr drücken."
  - Relevant rows tinted emerald, customer-standard label visible
  - Legacy bulk-select state + helpers removed (the Relevant checkbox
    now IS the primary mass action)
  - useMitigations gains handleSetRelevant, handleSetCustomerStandard,
    handleDeleteSilent (for non-confirm bulk deletes)

Future use: is_customer_standard mitigations from a prior project at the
same customer can later be auto-suggested when commissioning the next
plant — turning expert knowledge into reusable customer-profile data.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-05-17 14:35:56 +02:00
parent df7d83134b
commit 8f4f59f0e3
6 changed files with 191 additions and 96 deletions
@@ -160,8 +160,17 @@ type Mitigation struct {
VerificationResult string `json:"verification_result,omitempty"`
VerifiedAt *time.Time `json:"verified_at,omitempty"`
VerifiedBy uuid.UUID `json:"verified_by,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// IsRelevant marks the mitigation as applicable for this concrete project.
// Engine-suggested mitigations start with IsRelevant = false; the expert
// flips it to true (or deletes the mitigation) when walking through the
// Massnahmen tab. Only relevant mitigations surface in verification.
IsRelevant bool `json:"is_relevant"`
// IsCustomerStandard means the customer site already has this mitigation
// implemented as company-wide standard, so no evidence upload is needed.
// Implies IsRelevant = true (DB CHECK constraint).
IsCustomerStandard bool `json:"is_customer_standard"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// Evidence represents an uploaded file that serves as evidence for compliance
@@ -31,17 +31,20 @@ func (s *Store) CreateMitigation(ctx context.Context, req CreateMitigationReques
id, hazard_id, reduction_type, name, description,
status, verification_method, verification_result,
verified_at, verified_by,
is_relevant, is_customer_standard,
created_at, updated_at
) VALUES (
$1, $2, $3, $4, $5,
$6, $7, $8,
$9, $10,
$11, $12
$11, $12,
$13, $14
)
`,
m.ID, m.HazardID, string(m.ReductionType), m.Name, m.Description,
string(m.Status), "", "",
nil, uuid.Nil,
m.IsRelevant, m.IsCustomerStandard,
m.CreatedAt, m.UpdatedAt,
)
if err != nil {
@@ -79,6 +82,23 @@ func (s *Store) UpdateMitigation(ctx context.Context, id uuid.UUID, updates map[
query += fmt.Sprintf(", verification_method = $%d", argIdx)
args = append(args, val)
argIdx++
case "is_relevant":
query += fmt.Sprintf(", is_relevant = $%d", argIdx)
args = append(args, val)
argIdx++
case "is_customer_standard":
// CHECK constraint requires is_relevant=true when this is true,
// so we flip is_relevant on as well when the caller sets the
// customer-standard flag.
b, _ := val.(bool)
query += fmt.Sprintf(", is_customer_standard = $%d", argIdx)
args = append(args, b)
argIdx++
if b {
query += fmt.Sprintf(", is_relevant = $%d", argIdx)
args = append(args, true)
argIdx++
}
}
}
@@ -123,6 +143,7 @@ func (s *Store) ListMitigations(ctx context.Context, hazardID uuid.UUID) ([]Miti
id, hazard_id, reduction_type, name, description,
status, verification_method, verification_result,
verified_at, verified_by,
is_relevant, is_customer_standard,
created_at, updated_at
FROM iace_mitigations WHERE hazard_id = $1
ORDER BY created_at ASC
@@ -141,6 +162,7 @@ func (s *Store) ListMitigations(ctx context.Context, hazardID uuid.UUID) ([]Miti
&m.ID, &m.HazardID, &reductionType, &m.Name, &m.Description,
&status, &verificationMethod, &m.VerificationResult,
&m.VerifiedAt, &m.VerifiedBy,
&m.IsRelevant, &m.IsCustomerStandard,
&m.CreatedAt, &m.UpdatedAt,
)
if err != nil {
@@ -164,6 +186,7 @@ func (s *Store) ListMitigationsByProject(ctx context.Context, projectID uuid.UUI
m.id, m.hazard_id, m.reduction_type, m.name, m.description,
m.status, m.verification_method, m.verification_result,
m.verified_at, m.verified_by,
m.is_relevant, m.is_customer_standard,
m.created_at, m.updated_at
FROM iace_mitigations m
JOIN iace_hazards h ON h.id = m.hazard_id
@@ -184,6 +207,7 @@ func (s *Store) ListMitigationsByProject(ctx context.Context, projectID uuid.UUI
&m.ID, &m.HazardID, &reductionType, &m.Name, &m.Description,
&status, &verificationMethod, &m.VerificationResult,
&m.VerifiedAt, &m.VerifiedBy,
&m.IsRelevant, &m.IsCustomerStandard,
&m.CreatedAt, &m.UpdatedAt,
)
if err != nil {
@@ -224,12 +248,14 @@ func (s *Store) getMitigation(ctx context.Context, id uuid.UUID) (*Mitigation, e
id, hazard_id, reduction_type, name, description,
status, verification_method, verification_result,
verified_at, verified_by,
is_relevant, is_customer_standard,
created_at, updated_at
FROM iace_mitigations WHERE id = $1
`, id).Scan(
&m.ID, &m.HazardID, &reductionType, &m.Name, &m.Description,
&status, &verificationMethod, &m.VerificationResult,
&m.VerifiedAt, &m.VerifiedBy,
&m.IsRelevant, &m.IsCustomerStandard,
&m.CreatedAt, &m.UpdatedAt,
)
if err == pgx.ErrNoRows {