Files
breakpilot-compliance/admin-compliance/app/sdk/iace/[projectId]/customer-standards/page.tsx
T
Benjamin Admin a616b64273
CI / detect-changes (push) Successful in 10s
CI / guardrail-integrity (push) Has been skipped
CI / branch-name (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / validate-canonical-controls (push) Successful in 14s
CI / loc-budget (push) Failing after 19s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / test-go (push) Successful in 47s
CI / nodejs-build (push) Successful in 2m46s
CI / iace-gt-coverage (push) Successful in 28s
CI / test-python-backend (push) Has been skipped
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
feat(iace): Customer-Standard-Reuse across customer's prior projects
[migration-approved]

Task #22. The IACE module is used by a single Maschinenhersteller, but
their plants land at many different end customers. When the safety expert
commissions the second or third plant at the same customer, whole classes
of mitigations (company-wide PPE rules, locked-out energy isolation,
customer-standard signage) are already in place there — but rediscovered
from scratch every project.

Migration 031: iace_projects.customer_name TEXT + partial index.
  The customer is stored as a plain text field rather than a normalised
  iace_customers table (option A from the design discussion). A proper
  customer-management screen can promote this to a FK later without
  data loss.

Backend store_customer_standards.go:
  - ListCustomerStandardSuggestions(projectID, includeVerified) collects
    mitigations from all non-archived prior projects sharing the same
    tenant_id AND case-insensitive customer_name. Aggregates by
    mitigation.name (since same-named measures from different prior
    projects collapse into one suggestion) and surfaces:
      • source_project_count + source_project_names
      • is_customer_standard / has_verified_instances flags
    includeVerified=false → strictly is_customer_standard=true
    includeVerified=true  → also status='verified'
  - ImportCustomerStandardSuggestion(projectID, name): for every prior
    (mitigation.name → hazard.name) pairing, finds matching hazards in
    the current project (by name) and ensures a customer-standard
    mitigation exists. New rows via CreateMitigation (idempotent through
    the UNIQUE(hazard_id, name) from migration 030); existing rows are
    flipped to is_relevant=true + is_customer_standard=true +
    status='verified' via UPDATE.

Routes:
  GET  /api/v1/iace/projects/:id/customer-standards?include_verified=
  POST /api/v1/iace/projects/:id/customer-standards/import   body {name}

Frontend:
  - New page /sdk/iace/[projectId]/customer-standards with:
      • empty-state hint pointing to Auftrag → Kundenname
      • per-suggestion checkbox + per-row Übernehmen button
      • bulk "N übernehmen" button
      • toggle "Auch verifizierte einbeziehen" widening the pool
      • per-suggestion source_project_count + status badges
  - Sidebar item "Kundenstandards" (building icon) placed between
    Verifikation and Nachweise.
  - Order-page now mirrors Auftraggeber.Firmenname into the top-level
    customer_name column on save, so the Reuse feature is fed
    automatically without a separate input field.

The same expert effect from migration 029's is_customer_standard flag —
"I already know it's covered, no evidence needed" — now becomes a
cross-project asset rather than a per-project annotation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 22:31:30 +02:00

212 lines
9.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useParams } from 'next/navigation'
type Suggestion = {
name: string
reduction_type: 'design' | 'protection' | 'information' | string
description: string
source_project_count: number
source_project_names: string[]
is_customer_standard: boolean
has_verified_instances: boolean
}
type ProjectInfo = { customer_name?: string; machine_name?: string }
// /sdk/iace/[projectId]/customer-standards
//
// Surfaces mitigations that the expert flagged as "Kundenstandard" (or
// successfully verified) in earlier projects of the SAME customer. Picking
// one and clicking "Übernehmen" applies it to all matching hazards in the
// current project — every match is set to is_relevant=true,
// is_customer_standard=true, status='verified'. Saves the round-trip
// through Massnahmen + Verifikation for the cases where the safety expert
// already knows the answer from a prior plant at the same site.
//
// Filter "Auch verifizierte einbeziehen" widens the pool beyond strictly
// is_customer_standard=true to also include status='verified' rows — useful
// when the customer-standard habit is not yet established in the corpus.
export default function CustomerStandardsPage() {
const params = useParams()
const projectId = params.projectId as string
const [suggestions, setSuggestions] = useState<Suggestion[]>([])
const [project, setProject] = useState<ProjectInfo | null>(null)
const [loading, setLoading] = useState(true)
const [includeVerified, setIncludeVerified] = useState(false)
const [importing, setImporting] = useState<string | null>(null)
const [importedNames, setImportedNames] = useState<Set<string>>(new Set())
const [selected, setSelected] = useState<Set<string>>(new Set())
const [error, setError] = useState<string | null>(null)
const load = useCallback(async () => {
setLoading(true)
setError(null)
try {
const [sgRes, prRes] = await Promise.all([
fetch(`/api/sdk/v1/iace/projects/${projectId}/customer-standards?include_verified=${includeVerified}`),
fetch(`/api/sdk/v1/iace/projects/${projectId}`),
])
if (sgRes.ok) {
const j = await sgRes.json()
setSuggestions(j.suggestions || [])
}
if (prRes.ok) {
const j = await prRes.json()
const p = j.project || j
setProject({ customer_name: p.customer_name, machine_name: p.machine_name })
}
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setLoading(false)
}
}, [projectId, includeVerified])
useEffect(() => { load() }, [load])
function toggleSelect(name: string) {
setSelected((prev) => {
const next = new Set(prev)
if (next.has(name)) next.delete(name); else next.add(name)
return next
})
}
async function importOne(name: string) {
setImporting(name)
try {
const r = await fetch(`/api/sdk/v1/iace/projects/${projectId}/customer-standards/import`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
})
if (r.ok) {
setImportedNames((prev) => new Set(prev).add(name))
setSelected((prev) => { const n = new Set(prev); n.delete(name); return n })
} else {
const j = await r.json().catch(() => null)
setError(j?.error || `HTTP ${r.status}`)
}
} finally {
setImporting(null)
}
}
async function importSelected() {
const names = Array.from(selected)
for (const n of names) {
await importOne(n)
}
}
if (loading) return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
</div>
)
// No customer set → guide the user to set it first
const hasCustomer = !!(project?.customer_name && project.customer_name.trim() !== '')
if (!hasCustomer) {
return (
<div className="space-y-4 max-w-3xl">
<h1 className="text-2xl font-bold">Kundenstandards</h1>
<div className="rounded-md border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900">
Dieses Projekt hat noch keinen <em>Kundennamen</em>. Damit Massnahmen aus früheren
Anlagen desselben Kunden wiederverwendet werden können, trage den Kundennamen
unter <a className="text-purple-700 underline" href={`/sdk/iace/${projectId}/order`}>Auftrag Kunde</a> ein.
Sobald der Kundenname gesetzt ist, erscheint hier die Liste der wiederverwendbaren
Maßnahmen aus seinen Vorprojekten.
</div>
</div>
)
}
return (
<div className="space-y-4">
<div className="flex items-baseline justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Kundenstandards</h1>
<p className="mt-1 text-sm text-gray-500">
Übernimm Maßnahmen, die der Kunde <strong>{project?.customer_name}</strong> in
anderen Anlagen bereits als Standard etabliert hat. Übernehmen setzt sie für alle
passenden Gefährdungen <em>relevant</em> und <em>verifiziert</em> ohne Nachweis.
</p>
</div>
<div className="flex items-center gap-3">
<label className="flex items-center gap-1.5 text-xs text-gray-600">
<input type="checkbox" checked={includeVerified}
onChange={(e) => setIncludeVerified(e.target.checked)}
className="accent-purple-600" />
Auch <em>verifizierte</em> einbeziehen
</label>
{selected.size > 0 && (
<button onClick={importSelected} disabled={!!importing}
className="px-3 py-1.5 text-xs bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50">
{importing ? 'Übernehme…' : `${selected.size} übernehmen`}
</button>
)}
</div>
</div>
{error && <div className="text-red-600 text-sm">Fehler: {error}</div>}
{suggestions.length === 0 && (
<div className="rounded-md border border-gray-200 bg-gray-50 px-4 py-6 text-sm text-gray-600">
Keine wiederverwendbaren Maßnahmen für <strong>{project?.customer_name}</strong> gefunden.
{!includeVerified && ' Aktiviere „Auch verifizierte einbeziehen" oben rechts, um den Pool zu erweitern.'}
</div>
)}
{suggestions.length > 0 && (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
<div className="grid grid-cols-[28px_2fr_120px_100px_120px] gap-3 px-4 py-2 bg-gray-50 dark:bg-gray-750 text-xs font-medium text-gray-500 uppercase tracking-wider">
<div />
<div>Massnahme</div>
<div className="text-center">Vorprojekte</div>
<div>Status</div>
<div className="text-right">Aktion</div>
</div>
{suggestions.map((s) => {
const imported = importedNames.has(s.name)
return (
<div key={s.name} className={`grid grid-cols-[28px_2fr_120px_100px_120px] gap-3 px-4 py-2.5 border-t border-gray-100 dark:border-gray-700 ${imported ? 'bg-green-50/40' : ''} ${selected.has(s.name) ? 'bg-purple-50' : ''}`}>
<div className="pt-0.5">
<input type="checkbox" checked={selected.has(s.name)} onChange={() => toggleSelect(s.name)} disabled={imported}
className="accent-purple-600" />
</div>
<div className="min-w-0">
<div className="text-sm text-gray-900 dark:text-white">{s.name}</div>
{s.description && <div className="text-[11px] text-gray-500 mt-0.5 line-clamp-2">{s.description}</div>}
{s.source_project_names.length > 0 && (
<div className="text-[10px] text-gray-400 mt-1">aus: {s.source_project_names.slice(0,3).join(', ')}{s.source_project_names.length > 3 ? ` (+${s.source_project_names.length - 3})` : ''}</div>
)}
</div>
<div className="text-center self-center">
<span className="text-sm font-semibold text-purple-700">{s.source_project_count}×</span>
</div>
<div className="self-center flex flex-wrap gap-1">
{s.is_customer_standard && <span className="text-[10px] px-1.5 py-0.5 rounded bg-blue-100 text-blue-700">Kundenstandard</span>}
{s.has_verified_instances && !s.is_customer_standard && <span className="text-[10px] px-1.5 py-0.5 rounded bg-green-100 text-green-700">Verifiziert</span>}
</div>
<div className="text-right self-center">
{imported ? (
<span className="text-[11px] text-green-700"> Übernommen</span>
) : (
<button onClick={() => importOne(s.name)} disabled={!!importing}
className="px-2.5 py-1 text-[11px] bg-purple-600 text-white rounded hover:bg-purple-700 disabled:opacity-50">
{importing === s.name ? 'Übernehme…' : 'Übernehmen'}
</button>
)}
</div>
</div>
)
})}
</div>
)}
</div>
)
}