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:
@@ -13,6 +13,9 @@ export interface Mitigation {
|
|||||||
verified_by: string | null
|
verified_by: string | null
|
||||||
source?: string
|
source?: string
|
||||||
operational_states?: string[]
|
operational_states?: string[]
|
||||||
|
// Expert flags (migration 029).
|
||||||
|
is_relevant?: boolean
|
||||||
|
is_customer_standard?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Hazard {
|
export interface Hazard {
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ export function useMitigations(projectId: string) {
|
|||||||
created_at: (m.created_at || '') as string,
|
created_at: (m.created_at || '') as string,
|
||||||
verified_at: (m.verified_at || null) as string | null,
|
verified_at: (m.verified_at || null) as string | null,
|
||||||
verified_by: (m.verified_by || 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: (() => {
|
operational_states: (() => {
|
||||||
const ids = m.linked_hazard_ids ? (m.linked_hazard_ids as string[]) : m.hazard_id ? [m.hazard_id as string] : []
|
const ids = m.linked_hazard_ids ? (m.linked_hazard_ids as string[]) : m.hazard_id ? [m.hazard_id as string] : []
|
||||||
const states = new Set<string>()
|
const states = new Set<string>()
|
||||||
@@ -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 = {
|
const byType = {
|
||||||
design: mitigations.filter((m) => m.reduction_type === 'design'),
|
design: mitigations.filter((m) => m.reduction_type === 'design'),
|
||||||
protection: mitigations.filter((m) => m.reduction_type === 'protection'),
|
protection: mitigations.filter((m) => m.reduction_type === 'protection'),
|
||||||
@@ -159,7 +203,8 @@ export function useMitigations(projectId: string) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
mitigations, hazards, loading, hierarchyWarning, setHierarchyWarning,
|
mitigations, hazards, loading, hierarchyWarning, setHierarchyWarning,
|
||||||
measures, byType,
|
measures, byType, fetchData,
|
||||||
fetchMeasuresLibrary, handleSubmit, handleAddSuggestedMeasure, handleVerify, handleDelete,
|
fetchMeasuresLibrary, handleSubmit, handleAddSuggestedMeasure, handleVerify,
|
||||||
|
handleDelete, handleDeleteSilent, handleSetRelevant, handleSetCustomerStandard,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,8 +18,9 @@ export default function MitigationsPage() {
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
hazards, loading, hierarchyWarning, setHierarchyWarning,
|
hazards, loading, hierarchyWarning, setHierarchyWarning,
|
||||||
measures, byType,
|
measures, byType, fetchData,
|
||||||
fetchMeasuresLibrary, handleSubmit, handleAddSuggestedMeasure, handleVerify, handleDelete,
|
fetchMeasuresLibrary, handleSubmit, handleAddSuggestedMeasure,
|
||||||
|
handleDelete, handleDeleteSilent, handleSetRelevant,
|
||||||
} = useMitigations(projectId)
|
} = useMitigations(projectId)
|
||||||
|
|
||||||
const [measureNorms, setMeasureNorms] = useState<Record<string, string[]>>({})
|
const [measureNorms, setMeasureNorms] = useState<Record<string, string[]>>({})
|
||||||
@@ -47,8 +48,6 @@ export default function MitigationsPage() {
|
|||||||
const [showSuggest, setShowSuggest] = useState(false)
|
const [showSuggest, setShowSuggest] = useState(false)
|
||||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({ design: true, protection: true, information: true })
|
const [expanded, setExpanded] = useState<Record<string, boolean>>({ design: true, protection: true, information: true })
|
||||||
const [mitPages, setMitPages] = useState<Record<string, number>>({ design: 1, protection: 1, information: 1 })
|
const [mitPages, setMitPages] = useState<Record<string, number>>({ design: 1, protection: 1, information: 1 })
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
|
||||||
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
|
// Group-Expand: key = `${type}:${title}` so the same title in different
|
||||||
// reduction stages stays independently togglable.
|
// reduction stages stays independently togglable.
|
||||||
@@ -90,40 +89,6 @@ export default function MitigationsPage() {
|
|||||||
setExpanded((prev) => ({ ...prev, [type]: !prev[type] }))
|
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) {
|
function handleOpenLibrary(type?: string) {
|
||||||
setLibraryFilter(type)
|
setLibraryFilter(type)
|
||||||
fetchMeasuresLibrary(type)
|
fetchMeasuresLibrary(type)
|
||||||
@@ -157,24 +122,6 @@ export default function MitigationsPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{selected.size > 0 && (
|
|
||||||
<>
|
|
||||||
<span className="text-xs text-gray-500">{selected.size} ausgewaehlt</span>
|
|
||||||
<button onClick={handleBatchVerify} disabled={batchAction !== null}
|
|
||||||
className="px-3 py-1.5 text-xs bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50">
|
|
||||||
{batchAction === 'verify' ? 'Verifiziere...' : 'Verifizieren'}
|
|
||||||
</button>
|
|
||||||
<button onClick={handleBatchDelete} disabled={batchAction !== null}
|
|
||||||
className="px-3 py-1.5 text-xs bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50">
|
|
||||||
Loeschen
|
|
||||||
</button>
|
|
||||||
<button onClick={() => setSelected(new Set())} className="px-2 py-1.5 text-xs text-gray-500 hover:text-gray-700">
|
|
||||||
Abbrechen
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{selected.size === 0 && (
|
|
||||||
<>
|
|
||||||
<button onClick={() => setShowSuggest(true)}
|
<button onClick={() => setShowSuggest(true)}
|
||||||
className="px-3 py-1.5 text-xs border border-green-300 text-green-700 rounded-lg hover:bg-green-50">
|
className="px-3 py-1.5 text-xs border border-green-300 text-green-700 rounded-lg hover:bg-green-50">
|
||||||
Vorschlaege
|
Vorschlaege
|
||||||
@@ -187,13 +134,19 @@ export default function MitigationsPage() {
|
|||||||
className="px-3 py-1.5 text-xs bg-purple-600 text-white rounded-lg hover:bg-purple-700">
|
className="px-3 py-1.5 text-xs bg-purple-600 text-white rounded-lg hover:bg-purple-700">
|
||||||
+ Hinzufuegen
|
+ Hinzufuegen
|
||||||
</button>
|
</button>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hierarchyWarning && <HierarchyWarning onDismiss={() => setHierarchyWarning(false)} />}
|
{hierarchyWarning && <HierarchyWarning onDismiss={() => setHierarchyWarning(false)} />}
|
||||||
|
|
||||||
|
{/* Reinitialisieren-Warnung: nach manuellem Loeschen wuerde ein Reinit
|
||||||
|
die geloeschten Engine-Vorschlaege wiederherstellen. */}
|
||||||
|
<div className="rounded-md border border-amber-200 bg-amber-50 px-4 py-2.5 text-xs text-amber-900">
|
||||||
|
<strong>Hinweis:</strong> Markiere jede Maßnahme als <em>Relevant</em> (☑) oder lösche sie aus dem Projekt (🗑).
|
||||||
|
Nur als <em>relevant</em> markierte Maßnahmen erscheinen in der Verifikation.
|
||||||
|
<strong> Achtung:</strong> nach dem Löschen kein <em>Neu initialisieren</em> mehr drücken — sonst werden die gelöschten Vorschläge aus den Engine-Daten wiederhergestellt.
|
||||||
|
</div>
|
||||||
|
|
||||||
{showForm && (
|
{showForm && (
|
||||||
<MitigationForm
|
<MitigationForm
|
||||||
onSubmit={async (data) => { const ok = await handleSubmit(data); if (ok) setShowForm(false) }}
|
onSubmit={async (data) => { const ok = await handleSubmit(data); if (ok) setShowForm(false) }}
|
||||||
@@ -208,7 +161,6 @@ export default function MitigationsPage() {
|
|||||||
const config = REDUCTION_TYPES[type]
|
const config = REDUCTION_TYPES[type]
|
||||||
const items = byType[type]
|
const items = byType[type]
|
||||||
const isExpanded = expanded[type]
|
const isExpanded = expanded[type]
|
||||||
const allSelected = items.length > 0 && items.every((m) => selected.has(m.id))
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={type} className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
<div key={type} className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
@@ -233,39 +185,50 @@ export default function MitigationsPage() {
|
|||||||
return (
|
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_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 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>
|
<div title="Relevant fuer dieses Projekt">Relev.</div>
|
||||||
<input type="checkbox" checked={allSelected} onChange={() => selectAllInType(type)}
|
<div title="Loeschen">Lösch.</div>
|
||||||
className="accent-purple-600" title="Alle auswaehlen" />
|
|
||||||
</div>
|
|
||||||
<div>Massnahme</div>
|
<div>Massnahme</div>
|
||||||
<div className="text-right pr-2">Gefaehrdungen</div>
|
<div className="text-right pr-2">Gefährdungen</div>
|
||||||
<div>Status (P · I · V)</div>
|
<div>Status (P · I · V)</div>
|
||||||
</div>
|
</div>
|
||||||
{visibleGroups.map(({ title, instances }) => {
|
{visibleGroups.map(({ title, instances }) => {
|
||||||
const groupKey = `${type}:${title}`
|
const groupKey = `${type}:${title}`
|
||||||
const isGroupOpen = expandedGroup.has(groupKey)
|
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 counts = statusCounts(instances)
|
||||||
const refs = measureNorms[title.toLowerCase()]
|
const refs = measureNorms[title.toLowerCase()]
|
||||||
const first = instances[0]
|
const first = instances[0]
|
||||||
const description = first?.description || ''
|
const description = first?.description || ''
|
||||||
const catMatch = description.match(/Kategorie\s+(\S+)/)
|
const catMatch = description.match(/Kategorie\s+(\S+)/)
|
||||||
const category = catMatch?.[1]
|
const category = catMatch?.[1]
|
||||||
|
const relevantInGroup = instances.filter((m) => m.is_relevant).length
|
||||||
|
const allRelevant = relevantInGroup === instances.length
|
||||||
return (
|
return (
|
||||||
<div key={groupKey}>
|
<div key={groupKey}>
|
||||||
{/* Group header row */}
|
{/* Group header row */}
|
||||||
<div onClick={() => toggleGroup(groupKey)}
|
<div onClick={() => 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`}>
|
||||||
<div className="pt-0.5" onClick={(e) => e.stopPropagation()}>
|
<div className="pt-0.5" onClick={(e) => e.stopPropagation()}>
|
||||||
<input type="checkbox" checked={allInGroupSelected} onChange={() => {
|
<input type="checkbox" checked={allRelevant} ref={(el) => { if (el) el.indeterminate = !allRelevant && relevantInGroup > 0 }}
|
||||||
setSelected((prev) => {
|
onChange={async (e) => {
|
||||||
const next = new Set(prev)
|
const target = e.target.checked
|
||||||
if (allInGroupSelected) instances.forEach((m) => next.delete(m.id))
|
for (const m of instances) {
|
||||||
else instances.forEach((m) => next.add(m.id))
|
if (m.is_relevant !== target) await handleSetRelevant(m.id, target)
|
||||||
return next
|
}
|
||||||
})
|
}}
|
||||||
}} className="accent-purple-600" title={`Alle ${instances.length} Instanzen auswaehlen`} />
|
className="accent-purple-600" title={`${relevantInGroup}/${instances.length} als relevant markiert. Klick: alle als ${allRelevant ? 'nicht relevant' : 'relevant'} markieren.`} />
|
||||||
|
</div>
|
||||||
|
<div className="pt-0.5" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<button onClick={async () => {
|
||||||
|
if (!confirm(`Alle ${instances.length} Instanzen von "${title}" loeschen?`)) return
|
||||||
|
for (const m of instances) await handleDeleteSilent(m.id)
|
||||||
|
await fetchData()
|
||||||
|
}} className="text-gray-400 hover:text-red-600" title="Ganze Gruppe loeschen">
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6M1 7h22M16 7V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v3" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex items-start gap-1">
|
<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">
|
<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">
|
||||||
@@ -299,15 +262,25 @@ export default function MitigationsPage() {
|
|||||||
return (
|
return (
|
||||||
<div key={m.id}>
|
<div key={m.id}>
|
||||||
<div onClick={() => setExpandedMeasure(isDetailOpen ? null : 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' : ''}`}>
|
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' : ''}`}>
|
||||||
<div />
|
|
||||||
<div className="pt-0.5" onClick={(e) => e.stopPropagation()}>
|
<div className="pt-0.5" onClick={(e) => e.stopPropagation()}>
|
||||||
<input type="checkbox" checked={selected.has(m.id)} onChange={() => toggleSelect(m.id)}
|
<input type="checkbox" checked={Boolean(m.is_relevant)} onChange={() => handleSetRelevant(m.id, !m.is_relevant)}
|
||||||
className="accent-purple-600" />
|
className="accent-purple-600" title="Als relevant markieren" />
|
||||||
|
</div>
|
||||||
|
<div className="pt-0.5" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<button onClick={() => handleDelete(m.id)}
|
||||||
|
className="text-gray-400 hover:text-red-600" title="Loeschen">
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6M1 7h22M16 7V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v3" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-gray-600 dark:text-gray-300 min-w-0">
|
<div className="text-xs text-gray-600 dark:text-gray-300 min-w-0">
|
||||||
{(m.linked_hazard_names || []).join(', ') || '— (keine Gefaehrdung verknuepft)'}
|
{(m.linked_hazard_names || []).join(', ') || '— (keine Gefaehrdung verknuepft)'}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="text-[11px] text-gray-400 self-center text-right pr-2">
|
||||||
|
{m.is_customer_standard ? 'Kundenstandard' : ''}
|
||||||
|
</div>
|
||||||
<div><StatusBadge status={m.status} /></div>
|
<div><StatusBadge status={m.status} /></div>
|
||||||
</div>
|
</div>
|
||||||
{isDetailOpen && (
|
{isDetailOpen && (
|
||||||
|
|||||||
@@ -160,6 +160,15 @@ type Mitigation struct {
|
|||||||
VerificationResult string `json:"verification_result,omitempty"`
|
VerificationResult string `json:"verification_result,omitempty"`
|
||||||
VerifiedAt *time.Time `json:"verified_at,omitempty"`
|
VerifiedAt *time.Time `json:"verified_at,omitempty"`
|
||||||
VerifiedBy uuid.UUID `json:"verified_by,omitempty"`
|
VerifiedBy uuid.UUID `json:"verified_by,omitempty"`
|
||||||
|
// 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"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,17 +31,20 @@ func (s *Store) CreateMitigation(ctx context.Context, req CreateMitigationReques
|
|||||||
id, hazard_id, reduction_type, name, description,
|
id, hazard_id, reduction_type, name, description,
|
||||||
status, verification_method, verification_result,
|
status, verification_method, verification_result,
|
||||||
verified_at, verified_by,
|
verified_at, verified_by,
|
||||||
|
is_relevant, is_customer_standard,
|
||||||
created_at, updated_at
|
created_at, updated_at
|
||||||
) VALUES (
|
) VALUES (
|
||||||
$1, $2, $3, $4, $5,
|
$1, $2, $3, $4, $5,
|
||||||
$6, $7, $8,
|
$6, $7, $8,
|
||||||
$9, $10,
|
$9, $10,
|
||||||
$11, $12
|
$11, $12,
|
||||||
|
$13, $14
|
||||||
)
|
)
|
||||||
`,
|
`,
|
||||||
m.ID, m.HazardID, string(m.ReductionType), m.Name, m.Description,
|
m.ID, m.HazardID, string(m.ReductionType), m.Name, m.Description,
|
||||||
string(m.Status), "", "",
|
string(m.Status), "", "",
|
||||||
nil, uuid.Nil,
|
nil, uuid.Nil,
|
||||||
|
m.IsRelevant, m.IsCustomerStandard,
|
||||||
m.CreatedAt, m.UpdatedAt,
|
m.CreatedAt, m.UpdatedAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
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)
|
query += fmt.Sprintf(", verification_method = $%d", argIdx)
|
||||||
args = append(args, val)
|
args = append(args, val)
|
||||||
argIdx++
|
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,
|
id, hazard_id, reduction_type, name, description,
|
||||||
status, verification_method, verification_result,
|
status, verification_method, verification_result,
|
||||||
verified_at, verified_by,
|
verified_at, verified_by,
|
||||||
|
is_relevant, is_customer_standard,
|
||||||
created_at, updated_at
|
created_at, updated_at
|
||||||
FROM iace_mitigations WHERE hazard_id = $1
|
FROM iace_mitigations WHERE hazard_id = $1
|
||||||
ORDER BY created_at ASC
|
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,
|
&m.ID, &m.HazardID, &reductionType, &m.Name, &m.Description,
|
||||||
&status, &verificationMethod, &m.VerificationResult,
|
&status, &verificationMethod, &m.VerificationResult,
|
||||||
&m.VerifiedAt, &m.VerifiedBy,
|
&m.VerifiedAt, &m.VerifiedBy,
|
||||||
|
&m.IsRelevant, &m.IsCustomerStandard,
|
||||||
&m.CreatedAt, &m.UpdatedAt,
|
&m.CreatedAt, &m.UpdatedAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
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.id, m.hazard_id, m.reduction_type, m.name, m.description,
|
||||||
m.status, m.verification_method, m.verification_result,
|
m.status, m.verification_method, m.verification_result,
|
||||||
m.verified_at, m.verified_by,
|
m.verified_at, m.verified_by,
|
||||||
|
m.is_relevant, m.is_customer_standard,
|
||||||
m.created_at, m.updated_at
|
m.created_at, m.updated_at
|
||||||
FROM iace_mitigations m
|
FROM iace_mitigations m
|
||||||
JOIN iace_hazards h ON h.id = m.hazard_id
|
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,
|
&m.ID, &m.HazardID, &reductionType, &m.Name, &m.Description,
|
||||||
&status, &verificationMethod, &m.VerificationResult,
|
&status, &verificationMethod, &m.VerificationResult,
|
||||||
&m.VerifiedAt, &m.VerifiedBy,
|
&m.VerifiedAt, &m.VerifiedBy,
|
||||||
|
&m.IsRelevant, &m.IsCustomerStandard,
|
||||||
&m.CreatedAt, &m.UpdatedAt,
|
&m.CreatedAt, &m.UpdatedAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
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,
|
id, hazard_id, reduction_type, name, description,
|
||||||
status, verification_method, verification_result,
|
status, verification_method, verification_result,
|
||||||
verified_at, verified_by,
|
verified_at, verified_by,
|
||||||
|
is_relevant, is_customer_standard,
|
||||||
created_at, updated_at
|
created_at, updated_at
|
||||||
FROM iace_mitigations WHERE id = $1
|
FROM iace_mitigations WHERE id = $1
|
||||||
`, id).Scan(
|
`, id).Scan(
|
||||||
&m.ID, &m.HazardID, &reductionType, &m.Name, &m.Description,
|
&m.ID, &m.HazardID, &reductionType, &m.Name, &m.Description,
|
||||||
&status, &verificationMethod, &m.VerificationResult,
|
&status, &verificationMethod, &m.VerificationResult,
|
||||||
&m.VerifiedAt, &m.VerifiedBy,
|
&m.VerifiedAt, &m.VerifiedBy,
|
||||||
|
&m.IsRelevant, &m.IsCustomerStandard,
|
||||||
&m.CreatedAt, &m.UpdatedAt,
|
&m.CreatedAt, &m.UpdatedAt,
|
||||||
)
|
)
|
||||||
if err == pgx.ErrNoRows {
|
if err == pgx.ErrNoRows {
|
||||||
|
|||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user