From 8f4f59f0e384dfce9beb5e2cd0c0ab75c6ebbd86 Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Sun, 17 May 2026 14:35:56 +0200 Subject: [PATCH] feat(iace/mitigations): is_relevant + is_customer_standard flags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [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) --- .../mitigations/_components/types.tsx | 3 + .../mitigations/_hooks/useMitigations.ts | 49 +++++- .../sdk/iace/[projectId]/mitigations/page.tsx | 155 ++++++++---------- .../internal/iace/models_entities.go | 13 +- .../internal/iace/store_mitigations.go | 28 +++- .../029_iace_mitigation_relevance.sql | 39 +++++ 6 files changed, 191 insertions(+), 96 deletions(-) create mode 100644 ai-compliance-sdk/migrations/029_iace_mitigation_relevance.sql diff --git a/admin-compliance/app/sdk/iace/[projectId]/mitigations/_components/types.tsx b/admin-compliance/app/sdk/iace/[projectId]/mitigations/_components/types.tsx index ab1484dd..0e0c397f 100644 --- a/admin-compliance/app/sdk/iace/[projectId]/mitigations/_components/types.tsx +++ b/admin-compliance/app/sdk/iace/[projectId]/mitigations/_components/types.tsx @@ -13,6 +13,9 @@ export interface Mitigation { verified_by: string | null source?: string operational_states?: string[] + // Expert flags (migration 029). + is_relevant?: boolean + is_customer_standard?: boolean } export interface Hazard { diff --git a/admin-compliance/app/sdk/iace/[projectId]/mitigations/_hooks/useMitigations.ts b/admin-compliance/app/sdk/iace/[projectId]/mitigations/_hooks/useMitigations.ts index 78ee185d..b6619538 100644 --- a/admin-compliance/app/sdk/iace/[projectId]/mitigations/_hooks/useMitigations.ts +++ b/admin-compliance/app/sdk/iace/[projectId]/mitigations/_hooks/useMitigations.ts @@ -45,6 +45,8 @@ export function useMitigations(projectId: string) { created_at: (m.created_at || '') as string, verified_at: (m.verified_at || null) as string | null, verified_by: (m.verified_by || null) as string | null, + is_relevant: Boolean(m.is_relevant), + is_customer_standard: Boolean(m.is_customer_standard), operational_states: (() => { const ids = m.linked_hazard_ids ? (m.linked_hazard_ids as string[]) : m.hazard_id ? [m.hazard_id as string] : [] const states = new Set() @@ -151,6 +153,48 @@ export function useMitigations(projectId: string) { } } + // Bulk delete without per-row confirm; caller owns the confirm-step. + async function handleDeleteSilent(id: string) { + try { + const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations/${id}`, { method: 'DELETE' }) + if (!res.ok) console.error('delete failed for', id, res.status) + } catch (err) { + console.error('Failed to delete mitigation:', err) + } + } + + // Flag a mitigation as relevant for this project (or unflag). Optimistic: + // updates local state immediately, refetches afterwards. + async function handleSetRelevant(id: string, value: boolean) { + setMitigations((prev) => prev.map((m) => m.id === id ? { ...m, status: m.status } : m)) + try { + await fetch(`/api/sdk/v1/iace/mitigations/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ is_relevant: value }), + }) + await fetchData() + } catch (err) { + console.error('Failed to set relevant flag:', err) + } + } + + // Mark a mitigation as "customer standard" — already implemented at the + // customer's site, no evidence required. Implies is_relevant=true (server + // enforces this via the CHECK constraint). + async function handleSetCustomerStandard(id: string, value: boolean) { + try { + await fetch(`/api/sdk/v1/iace/mitigations/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ is_customer_standard: value }), + }) + await fetchData() + } catch (err) { + console.error('Failed to set customer-standard flag:', err) + } + } + const byType = { design: mitigations.filter((m) => m.reduction_type === 'design'), protection: mitigations.filter((m) => m.reduction_type === 'protection'), @@ -159,7 +203,8 @@ export function useMitigations(projectId: string) { return { mitigations, hazards, loading, hierarchyWarning, setHierarchyWarning, - measures, byType, - fetchMeasuresLibrary, handleSubmit, handleAddSuggestedMeasure, handleVerify, handleDelete, + measures, byType, fetchData, + fetchMeasuresLibrary, handleSubmit, handleAddSuggestedMeasure, handleVerify, + handleDelete, handleDeleteSilent, handleSetRelevant, handleSetCustomerStandard, } } diff --git a/admin-compliance/app/sdk/iace/[projectId]/mitigations/page.tsx b/admin-compliance/app/sdk/iace/[projectId]/mitigations/page.tsx index 6ad056c3..2de91320 100644 --- a/admin-compliance/app/sdk/iace/[projectId]/mitigations/page.tsx +++ b/admin-compliance/app/sdk/iace/[projectId]/mitigations/page.tsx @@ -18,8 +18,9 @@ export default function MitigationsPage() { const { hazards, loading, hierarchyWarning, setHierarchyWarning, - measures, byType, - fetchMeasuresLibrary, handleSubmit, handleAddSuggestedMeasure, handleVerify, handleDelete, + measures, byType, fetchData, + fetchMeasuresLibrary, handleSubmit, handleAddSuggestedMeasure, + handleDelete, handleDeleteSilent, handleSetRelevant, } = useMitigations(projectId) const [measureNorms, setMeasureNorms] = useState>({}) @@ -47,8 +48,6 @@ export default function MitigationsPage() { const [showSuggest, setShowSuggest] = useState(false) const [expanded, setExpanded] = useState>({ design: true, protection: true, information: true }) const [mitPages, setMitPages] = useState>({ design: 1, protection: 1, information: 1 }) - 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. @@ -90,40 +89,6 @@ export default function MitigationsPage() { 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) @@ -157,43 +122,31 @@ export default function MitigationsPage() {

- {selected.size > 0 && ( - <> - {selected.size} ausgewaehlt - - - - - )} - {selected.size === 0 && ( - <> - - - - - )} + + +
{hierarchyWarning && setHierarchyWarning(false)} />} + {/* Reinitialisieren-Warnung: nach manuellem Loeschen wuerde ein Reinit + die geloeschten Engine-Vorschlaege wiederherstellen. */} +
+ Hinweis: Markiere jede Maßnahme als Relevant (☑) oder lösche sie aus dem Projekt (🗑). + Nur als relevant markierte Maßnahmen erscheinen in der Verifikation. + Achtung: nach dem Löschen kein Neu initialisieren mehr drücken — sonst werden die gelöschten Vorschläge aus den Engine-Daten wiederhergestellt. +
+ {showForm && ( { const ok = await handleSubmit(data); if (ok) setShowForm(false) }} @@ -208,7 +161,6 @@ export default function MitigationsPage() { 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 (
@@ -233,39 +185,50 @@ export default function MitigationsPage() { return (
{/* Table header */} -
-
- selectAllInType(type)} - className="accent-purple-600" title="Alle auswaehlen" /> -
+
+
Relev.
+
Lösch.
Massnahme
-
Gefaehrdungen
+
Gefährdungen
Status (P · I · V)
{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)) + // (legacy bulk-select removed — Relevant-checkbox is now the primary mass-action) 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 relevantInGroup = instances.filter((m) => m.is_relevant).length + const allRelevant = relevantInGroup === instances.length return (
{/* 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' : ''}`}> + className={`grid grid-cols-[36px_36px_2fr_120px_110px] 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`}>
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`} /> + { if (el) el.indeterminate = !allRelevant && relevantInGroup > 0 }} + onChange={async (e) => { + const target = e.target.checked + for (const m of instances) { + if (m.is_relevant !== target) await handleSetRelevant(m.id, target) + } + }} + className="accent-purple-600" title={`${relevantInGroup}/${instances.length} als relevant markiert. Klick: alle als ${allRelevant ? 'nicht relevant' : 'relevant'} markieren.`} /> +
+
e.stopPropagation()}> +
@@ -299,15 +262,25 @@ export default function MitigationsPage() { 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' : ''}`}> -
+ className={`grid grid-cols-[36px_36px_2fr_120px_110px] 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 ${m.is_relevant ? 'bg-emerald-50/40 dark:bg-emerald-900/10' : ''}`}>
e.stopPropagation()}> - toggleSelect(m.id)} - className="accent-purple-600" /> + handleSetRelevant(m.id, !m.is_relevant)} + className="accent-purple-600" title="Als relevant markieren" /> +
+
e.stopPropagation()}> +
{(m.linked_hazard_names || []).join(', ') || '— (keine Gefaehrdung verknuepft)'}
+
+ {m.is_customer_standard ? 'Kundenstandard' : ''} +
{isDetailOpen && ( diff --git a/ai-compliance-sdk/internal/iace/models_entities.go b/ai-compliance-sdk/internal/iace/models_entities.go index 3bdc9127..60fa68ae 100644 --- a/ai-compliance-sdk/internal/iace/models_entities.go +++ b/ai-compliance-sdk/internal/iace/models_entities.go @@ -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 diff --git a/ai-compliance-sdk/internal/iace/store_mitigations.go b/ai-compliance-sdk/internal/iace/store_mitigations.go index 0c2f9415..0e208b80 100644 --- a/ai-compliance-sdk/internal/iace/store_mitigations.go +++ b/ai-compliance-sdk/internal/iace/store_mitigations.go @@ -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 { diff --git a/ai-compliance-sdk/migrations/029_iace_mitigation_relevance.sql b/ai-compliance-sdk/migrations/029_iace_mitigation_relevance.sql new file mode 100644 index 00000000..1f3f0b78 --- /dev/null +++ b/ai-compliance-sdk/migrations/029_iace_mitigation_relevance.sql @@ -0,0 +1,39 @@ +-- Migration 029: IACE Mitigation Relevance + Customer-Standard flag +-- ========================================================================== +-- The engine generates ~80 mitigations per project (Bremsscheibe benchmark). +-- Many are not applicable for a specific customer site — e.g. the customer +-- has 30 of them already implemented as company-wide standard. To keep the +-- verification step meaningful, the expert needs to: +-- +-- 1. Mark each mitigation as relevant (or delete it from the project), +-- 2. Optionally flag it as "customer standard — no evidence required". +-- +-- This is the difference between "applicable, must be verified" and +-- "applicable, but the expert already knows it's covered by the customer's +-- existing setup". Both must reach the verification report; only the first +-- needs an evidence upload. +-- +-- A later feature reuses is_customer_standard to suggest pre-marked +-- mitigations when the same customer commissions another plant assessment. +-- ========================================================================== + +-- is_relevant: Fachmann hat die Massnahme als anwendbar bestaetigt. +-- FALSE → Engine-Vorschlag, vom Fachmann noch nicht bewertet. +-- TRUE → Fachmann hat 'Relevant' angekreuzt; geht in die Verifikation. +-- is_customer_standard: Beim Kunden bereits implementiert. +-- FALSE → benötigt Nachweis in der Verifikation. +-- TRUE → keine Evidence-Datei notwendig; gilt als verifiziert. +ALTER TABLE iace_mitigations + ADD COLUMN IF NOT EXISTS is_relevant BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN IF NOT EXISTS is_customer_standard BOOLEAN NOT NULL DEFAULT FALSE; + +-- An is_customer_standard mitigation is by definition relevant. +ALTER TABLE iace_mitigations + DROP CONSTRAINT IF EXISTS iace_mitigations_customer_standard_chk, + ADD CONSTRAINT iace_mitigations_customer_standard_chk + CHECK (is_customer_standard = FALSE OR is_relevant = TRUE); + +-- Index for the verification-page filter (`WHERE is_relevant = TRUE`). +CREATE INDEX IF NOT EXISTS idx_iace_mitigations_relevant + ON iace_mitigations(is_relevant) + WHERE is_relevant = TRUE;