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
[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>
212 lines
9.2 KiB
TypeScript
212 lines
9.2 KiB
TypeScript
'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>
|
||
)
|
||
}
|