feat(iace/verification): derived view on relevant mitigations + 2 actions
Task #21. The verification page used to manage a separate VerificationItem entity that the expert had to populate by hand — disjoint from the actual mitigations list. With the is_relevant flag from migration 029, the verification step has a natural definition: confirm completion for every mitigation the expert flagged as relevant for this project. Page is now a derived view on useMitigations(): filter is_relevant=true, group by title (same dedupe as Massnahmen page), expose two actions per hazard×mitigation row: 1. "Kundenstandard" — already implemented at the customer's site, no evidence file required. Sets is_customer_standard=true and status='verified'. 2. "Verifizieren…" — opens a modal asking for a textual evidence reference (Prüfprotokoll-Nr, audit reference, etc.). Calls the existing POST /mitigations/:mid/verify with verification_result. File upload is deferred to phase 2 once an object-storage backend is in place — the modal explains this. When a row is verified, a "Zurücksetzen" link reverts status to 'implemented' for accidental confirmations. Header counters: total relevant / open / verified / Kundenstandard. Maßnahmen-page polish (same commit): - "Lösch."-column header removed — the trash icon is self-explanatory - groupByTitle now additionally deduplicates by hazard_id within a group (engine occasionally emits duplicate (name, hazard_id) pairs when Reinit is clicked twice; a follow-up migration 030 will add a UNIQUE constraint to prevent these upstream) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -64,6 +64,14 @@ export default function MitigationsPage() {
|
|||||||
// Mitigations sharing the same title (e.g. "Sicherheitszeichen nach ISO 7010"
|
// Mitigations sharing the same title (e.g. "Sicherheitszeichen nach ISO 7010"
|
||||||
// applied to 21 hazards) collapse into a single group row. Each instance
|
// applied to 21 hazards) collapse into a single group row. Each instance
|
||||||
// keeps its own DB id, status and notes — the grouping is presentation-only.
|
// keeps its own DB id, status and notes — the grouping is presentation-only.
|
||||||
|
//
|
||||||
|
// Within a group we additionally deduplicate by hazard_id: the engine
|
||||||
|
// sometimes emits the same (name, hazard_id) pair twice when "Neu
|
||||||
|
// initialisieren" is clicked repeatedly. We pick the row that already
|
||||||
|
// carries user state (is_relevant=true preferred, then newest created_at)
|
||||||
|
// so the expert's decisions are not lost. The DB still holds both rows;
|
||||||
|
// a separate migration adds a UNIQUE(hazard_id, name) constraint to
|
||||||
|
// prevent the duplicates upstream.
|
||||||
function groupByTitle(items: Mitigation[]): Array<{ title: string; instances: Mitigation[] }> {
|
function groupByTitle(items: Mitigation[]): Array<{ title: string; instances: Mitigation[] }> {
|
||||||
const map = new Map<string, Mitigation[]>()
|
const map = new Map<string, Mitigation[]>()
|
||||||
for (const m of items) {
|
for (const m of items) {
|
||||||
@@ -71,7 +79,18 @@ export default function MitigationsPage() {
|
|||||||
const arr = map.get(key)
|
const arr = map.get(key)
|
||||||
if (arr) arr.push(m); else map.set(key, [m])
|
if (arr) arr.push(m); else map.set(key, [m])
|
||||||
}
|
}
|
||||||
return Array.from(map.entries()).map(([title, instances]) => ({ title, instances }))
|
return Array.from(map.entries()).map(([title, instances]) => {
|
||||||
|
const byHazard = new Map<string, Mitigation>()
|
||||||
|
for (const m of instances) {
|
||||||
|
const hid = (m.linked_hazard_ids || []).join('|') || m.id
|
||||||
|
const prev = byHazard.get(hid)
|
||||||
|
if (!prev) { byHazard.set(hid, m); continue }
|
||||||
|
// Tie-break: prefer is_relevant=true, then newest created_at
|
||||||
|
const score = (x: Mitigation) => (x.is_relevant ? 2 : 0) + (x.created_at > (prev.created_at || '') ? 1 : 0)
|
||||||
|
if (score(m) > score(prev)) byHazard.set(hid, m)
|
||||||
|
}
|
||||||
|
return { title, instances: Array.from(byHazard.values()) }
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compact status distribution: returns counts for the three known states.
|
// Compact status distribution: returns counts for the three known states.
|
||||||
@@ -187,7 +206,7 @@ export default function MitigationsPage() {
|
|||||||
{/* Table header */}
|
{/* Table header */}
|
||||||
<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 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 title="Relevant fuer dieses Projekt">Relev.</div>
|
<div title="Relevant fuer dieses Projekt">Relev.</div>
|
||||||
<div title="Loeschen">Lösch.</div>
|
<div />
|
||||||
<div>Massnahme</div>
|
<div>Massnahme</div>
|
||||||
<div className="text-right pr-2">Gefährdungen</div>
|
<div className="text-right pr-2">Gefährdungen</div>
|
||||||
<div>Status (P · I · V)</div>
|
<div>Status (P · I · V)</div>
|
||||||
|
|||||||
@@ -1,86 +1,41 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react'
|
import { useState } from 'react'
|
||||||
import { useParams } from 'next/navigation'
|
import { useParams } from 'next/navigation'
|
||||||
import type { VerificationItem, VerificationFormData } from './_components/verification-types'
|
import { useMitigations } from '../mitigations/_hooks/useMitigations'
|
||||||
import { VerificationForm } from './_components/VerificationForm'
|
import type { Mitigation } from '../mitigations/_components/types'
|
||||||
import { CompleteModal } from './_components/CompleteModal'
|
|
||||||
import { SuggestEvidenceModal } from './_components/SuggestEvidenceModal'
|
// Verifikations-Page (Phase-1 Workflow):
|
||||||
import { VerificationTable } from './_components/VerificationTable'
|
//
|
||||||
|
// Diese Seite ist eine abgeleitete View auf die Maßnahmen-Liste. Sie zeigt
|
||||||
|
// nur diejenigen Maßnahmen, die der Fachmann auf der Maßnahmen-Seite als
|
||||||
|
// `is_relevant = true` markiert hat. Pro Maßnahme stehen zwei Aktionen
|
||||||
|
// zur Verfügung:
|
||||||
|
//
|
||||||
|
// 1. "Beim Kunden Standard" — Die Maßnahme ist beim Kunden bereits
|
||||||
|
// umgesetzt (z.B. firmenweite Vorgabe, identische Vor-Anlage).
|
||||||
|
// Setzt is_customer_standard = true und status = verified.
|
||||||
|
// Es ist kein Nachweis-Dokument erforderlich.
|
||||||
|
//
|
||||||
|
// 2. "Verifizieren (mit Nachweis)" — Öffnet ein Modal, in dem der
|
||||||
|
// Verifizierer einen Text-Nachweis hinterlegt (Prüfprotokoll-Nummer,
|
||||||
|
// Abnahme-Referenz, etc.). Setzt status = verified. Die File-Upload-
|
||||||
|
// Variante folgt in Phase 2, sobald ein Object-Storage-Backend
|
||||||
|
// verfügbar ist.
|
||||||
|
//
|
||||||
|
// Wenn die Maßnahme bereits verifiziert ist, wird ein "Zurücksetzen"-Link
|
||||||
|
// angeboten — er stellt status auf 'implemented' zurück, damit der
|
||||||
|
// Fachmann eine versehentliche Bestätigung rückgängig machen kann.
|
||||||
|
|
||||||
export default function VerificationPage() {
|
export default function VerificationPage() {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const projectId = params.projectId as string
|
const projectId = params.projectId as string
|
||||||
const [items, setItems] = useState<VerificationItem[]>([])
|
|
||||||
const [hazards, setHazards] = useState<{ id: string; name: string }[]>([])
|
|
||||||
const [mitigations, setMitigations] = useState<{ id: string; title: string }[]>([])
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [showForm, setShowForm] = useState(false)
|
|
||||||
const [completingItem, setCompletingItem] = useState<VerificationItem | null>(null)
|
|
||||||
const [showSuggest, setShowSuggest] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => { fetchData() }, [projectId])
|
const { byType, loading, handleSetCustomerStandard } = useMitigations(projectId)
|
||||||
|
|
||||||
async function fetchData() {
|
const [verifyTarget, setVerifyTarget] = useState<Mitigation | null>(null)
|
||||||
try {
|
const [verifyResult, setVerifyResult] = useState('')
|
||||||
// Only load verifications initially — hazards/mitigations loaded on demand
|
const [submitting, setSubmitting] = useState(false)
|
||||||
const verRes = await fetch(`/api/sdk/v1/iace/projects/${projectId}/verifications`)
|
|
||||||
if (verRes.ok) { const j = await verRes.json(); setItems(j.verifications || j || []) }
|
|
||||||
} catch (err) { console.error('Failed to fetch data:', err) }
|
|
||||||
finally { setLoading(false) }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadMitigationsIfNeeded() {
|
|
||||||
if (mitigations.length > 0) return
|
|
||||||
try {
|
|
||||||
const mitRes = await fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations`)
|
|
||||||
if (mitRes.ok) {
|
|
||||||
const j = await mitRes.json()
|
|
||||||
const mits = (j.mitigations || j || []).map((m: Record<string, string>) => ({ id: m.id, title: m.title || m.name || '' }))
|
|
||||||
setMitigations(mits)
|
|
||||||
}
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSubmit(data: VerificationFormData) {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/verifications`, {
|
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data),
|
|
||||||
})
|
|
||||||
if (res.ok) { setShowForm(false); await fetchData() }
|
|
||||||
} catch (err) { console.error('Failed to add verification:', err) }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleAddSuggestedEvidence(title: string, description: string, method: string, mitigationId: string) {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/verifications`, {
|
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ title, description, method, linked_mitigation_id: mitigationId }),
|
|
||||||
})
|
|
||||||
if (res.ok) await fetchData()
|
|
||||||
} catch (err) { console.error('Failed to add suggested evidence:', err) }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleComplete(id: string, result: string, passed: boolean) {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/verifications/${id}/complete`, {
|
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ result, passed }),
|
|
||||||
})
|
|
||||||
if (res.ok) { setCompletingItem(null); await fetchData() }
|
|
||||||
} catch (err) { console.error('Failed to complete verification:', err) }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDelete(id: string) {
|
|
||||||
if (!confirm('Verifikation wirklich loeschen?')) return
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/verifications/${id}`, { method: 'DELETE' })
|
|
||||||
if (res.ok) await fetchData()
|
|
||||||
} catch (err) { console.error('Failed to delete verification:', err) }
|
|
||||||
}
|
|
||||||
|
|
||||||
const completed = items.filter(i => i.status === 'completed').length
|
|
||||||
const failed = items.filter(i => i.status === 'failed').length
|
|
||||||
const pending = items.filter(i => i.status === 'pending' || i.status === 'in_progress').length
|
|
||||||
|
|
||||||
if (loading) return (
|
if (loading) return (
|
||||||
<div className="flex items-center justify-center h-64">
|
<div className="flex items-center justify-center h-64">
|
||||||
@@ -88,82 +43,191 @@ export default function VerificationPage() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const allRelevant = [...byType.design, ...byType.protection, ...byType.information].filter((m) => m.is_relevant)
|
||||||
|
const groups = groupByTitle(allRelevant)
|
||||||
|
const totals = {
|
||||||
|
total: allRelevant.length,
|
||||||
|
verified: allRelevant.filter((m) => m.status === 'verified').length,
|
||||||
|
customerStd: allRelevant.filter((m) => m.is_customer_standard).length,
|
||||||
|
pending: allRelevant.filter((m) => m.status !== 'verified').length,
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setStatus(id: string, value: 'implemented' | 'verified') {
|
||||||
|
await fetch(`/api/sdk/v1/iace/mitigations/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ status: value }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitVerify() {
|
||||||
|
if (!verifyTarget) return
|
||||||
|
setSubmitting(true)
|
||||||
|
try {
|
||||||
|
await fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations/${verifyTarget.id}/verify`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ verification_result: verifyResult }),
|
||||||
|
})
|
||||||
|
// Refetch via window-reload of just the data — useMitigations refreshes on mount.
|
||||||
|
window.location.reload()
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
setVerifyTarget(null)
|
||||||
|
setVerifyResult('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div>
|
||||||
<div>
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Verifikation</h1>
|
||||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Verifikationsplan</h1>
|
<p className="mt-1 text-sm text-gray-500">
|
||||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">Nachweisfuehrung fuer alle Schutzmassnahmen und Sicherheitsanforderungen.</p>
|
Bestätige die Umsetzung jeder als relevant markierten Maßnahme — entweder als
|
||||||
</div>
|
<em> Kundenstandard</em> (keine Nachweis-Datei nötig) oder mit hinterlegtem Nachweis.
|
||||||
<div className="flex items-center gap-2">
|
</p>
|
||||||
{true && (
|
|
||||||
<button onClick={async () => { await loadMitigationsIfNeeded(); setShowSuggest(true) }} className="flex items-center gap-2 px-3 py-2 border border-green-300 text-green-700 rounded-lg hover:bg-green-50 transition-colors text-sm">
|
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
|
|
||||||
</svg>
|
|
||||||
Nachweise vorschlagen
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button onClick={() => setShowForm(true)} className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors">
|
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
|
||||||
</svg>
|
|
||||||
Verifikation hinzufuegen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{items.length > 0 && (
|
{totals.total === 0 ? (
|
||||||
<div className="grid grid-cols-4 gap-3">
|
<div className="rounded-md border border-gray-200 bg-gray-50 px-4 py-6 text-sm text-gray-600">
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4 text-center">
|
Keine als <em>relevant</em> markierten Maßnahmen vorhanden. Gehe zurück zur
|
||||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">{items.length}</div>
|
{' '}<a className="text-purple-600 underline" href={`/sdk/iace/${projectId}/mitigations`}>Maßnahmen-Seite</a>{' '}
|
||||||
<div className="text-xs text-gray-500">Gesamt</div>
|
und kreuze die anwendbaren Maßnahmen an.
|
||||||
</div>
|
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-green-200 p-4 text-center">
|
|
||||||
<div className="text-2xl font-bold text-green-600">{completed}</div>
|
|
||||||
<div className="text-xs text-green-600">Abgeschlossen</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-red-200 p-4 text-center">
|
|
||||||
<div className="text-2xl font-bold text-red-600">{failed}</div>
|
|
||||||
<div className="text-xs text-red-600">Fehlgeschlagen</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-yellow-200 p-4 text-center">
|
|
||||||
<div className="text-2xl font-bold text-yellow-600">{pending}</div>
|
|
||||||
<div className="text-xs text-yellow-600">Ausstehend</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-4 gap-3">
|
||||||
|
<Stat n={totals.total} label="relevant" tone="gray" />
|
||||||
|
<Stat n={totals.pending} label="offen" tone="amber" />
|
||||||
|
<Stat n={totals.verified} label="verifiziert" tone="green" />
|
||||||
|
<Stat n={totals.customerStd} label="Kundenstandard" tone="blue" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{groups.map(({ title, instances }) => {
|
||||||
|
const verifiedCount = instances.filter((m) => m.status === 'verified').length
|
||||||
|
const allDone = verifiedCount === instances.length
|
||||||
|
return (
|
||||||
|
<div key={title} className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
|
<div className={`flex items-center gap-3 px-4 py-3 ${allDone ? 'bg-green-50 dark:bg-green-900/20' : 'bg-gray-50 dark:bg-gray-750'}`}>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-sm font-semibold text-gray-900 dark:text-white">{title}</div>
|
||||||
|
<div className="text-xs text-gray-500">{verifiedCount}/{instances.length} verifiziert</div>
|
||||||
|
</div>
|
||||||
|
{allDone && (
|
||||||
|
<svg className="w-5 h-5 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||||
|
{instances.map((m) => {
|
||||||
|
const isVerified = m.status === 'verified'
|
||||||
|
return (
|
||||||
|
<div key={m.id} className={`grid grid-cols-[1fr_240px] gap-3 px-4 py-2.5 items-center ${isVerified ? 'bg-green-50/30 dark:bg-green-900/10' : ''}`}>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-sm text-gray-700 dark:text-gray-200">
|
||||||
|
{(m.linked_hazard_names || []).join(', ') || '— (keine Gefährdung verknüpft)'}
|
||||||
|
</div>
|
||||||
|
{m.is_customer_standard && (
|
||||||
|
<div className="text-[11px] text-blue-600 mt-0.5">Beim Kunden Standard</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
{!isVerified ? (
|
||||||
|
<>
|
||||||
|
<button onClick={async () => {
|
||||||
|
await handleSetCustomerStandard(m.id, true)
|
||||||
|
await setStatus(m.id, 'verified')
|
||||||
|
window.location.reload()
|
||||||
|
}} className="px-2.5 py-1 text-[11px] border border-blue-300 text-blue-700 rounded hover:bg-blue-50">
|
||||||
|
Kundenstandard
|
||||||
|
</button>
|
||||||
|
<button onClick={() => { setVerifyTarget(m); setVerifyResult('') }}
|
||||||
|
className="px-2.5 py-1 text-[11px] bg-green-600 text-white rounded hover:bg-green-700">
|
||||||
|
Verifizieren…
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="text-[11px] text-green-700">✓ Verifiziert</span>
|
||||||
|
<button onClick={async () => {
|
||||||
|
if (!confirm('Verifizierung zurücksetzen?')) return
|
||||||
|
await setStatus(m.id, 'implemented')
|
||||||
|
window.location.reload()
|
||||||
|
}} className="text-[11px] text-gray-400 hover:text-red-600 underline">
|
||||||
|
Zurücksetzen
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showForm && <VerificationForm onSubmit={handleSubmit} onCancel={() => setShowForm(false)} hazards={hazards} mitigations={mitigations} />}
|
{verifyTarget && (
|
||||||
{completingItem && <CompleteModal item={completingItem} onSubmit={handleComplete} onClose={() => setCompletingItem(null)} />}
|
<div className="fixed inset-0 bg-black/40 flex items-center justify-center p-4 z-50">
|
||||||
{showSuggest && <SuggestEvidenceModal mitigations={mitigations} projectId={projectId} onAddEvidence={handleAddSuggestedEvidence} onClose={() => setShowSuggest(false)} />}
|
<div className="bg-white dark:bg-gray-800 rounded-xl max-w-lg w-full p-6 space-y-4">
|
||||||
|
<div>
|
||||||
{items.length > 0 ? (
|
<h2 className="text-lg font-semibold">Verifizieren</h2>
|
||||||
<VerificationTable items={items} onComplete={setCompletingItem} onDelete={handleDelete} />
|
<p className="text-sm text-gray-500 mt-1">{verifyTarget.title}</p>
|
||||||
) : !showForm && (
|
<p className="text-xs text-gray-400 mt-0.5">{(verifyTarget.linked_hazard_names || []).join(', ')}</p>
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-12 text-center">
|
</div>
|
||||||
<div className="w-16 h-16 mx-auto bg-purple-100 dark:bg-purple-900/30 rounded-full flex items-center justify-center mb-4">
|
<div>
|
||||||
<svg className="w-8 h-8 text-purple-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<label className="block text-xs font-medium text-gray-700 mb-1">Nachweis / Prüfprotokoll-Referenz</label>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
<textarea value={verifyResult} onChange={(e) => setVerifyResult(e.target.value)}
|
||||||
</svg>
|
placeholder="z.B. Prüfprotokoll PM-2026-014 vom 14.05.2026, durchgeführt durch Hr. Schmidt (TÜV Süd)"
|
||||||
</div>
|
className="w-full border rounded px-3 py-2 text-sm h-24" />
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Kein Verifikationsplan vorhanden</h3>
|
<p className="text-[10px] text-gray-400 mt-1">Datei-Upload folgt in Phase 2 — vorerst genügt eine eindeutige Referenz.</p>
|
||||||
<p className="mt-2 text-gray-500 max-w-md mx-auto">
|
</div>
|
||||||
Definieren Sie Verifikationsschritte fuer Ihre Schutzmassnahmen.
|
<div className="flex items-center justify-end gap-2">
|
||||||
Jede Massnahme sollte durch mindestens eine Verifikation abgedeckt sein.
|
<button onClick={() => setVerifyTarget(null)} disabled={submitting} className="text-xs px-3 py-1.5 text-gray-500 hover:text-gray-700">Abbrechen</button>
|
||||||
</p>
|
<button onClick={submitVerify} disabled={submitting || !verifyResult.trim()}
|
||||||
<div className="mt-6 flex items-center justify-center gap-3">
|
className="px-3 py-1.5 text-xs bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50">
|
||||||
{mitigations.length > 0 && (
|
{submitting ? 'Speichere…' : 'Verifizieren'}
|
||||||
<button onClick={async () => { await loadMitigationsIfNeeded(); setShowSuggest(true) }} className="px-6 py-3 border border-green-300 text-green-700 rounded-lg hover:bg-green-50 transition-colors">
|
|
||||||
Nachweise vorschlagen
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
</div>
|
||||||
<button onClick={() => setShowForm(true)} className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors">
|
|
||||||
Erste Verifikation anlegen
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Stat({ n, label, tone }: { n: number; label: string; tone: 'gray' | 'amber' | 'green' | 'blue' }) {
|
||||||
|
const color =
|
||||||
|
tone === 'amber' ? 'text-amber-600 border-amber-200' :
|
||||||
|
tone === 'green' ? 'text-green-600 border-green-200' :
|
||||||
|
tone === 'blue' ? 'text-blue-600 border-blue-200' :
|
||||||
|
'text-gray-700 border-gray-200'
|
||||||
|
return (
|
||||||
|
<div className={`bg-white dark:bg-gray-800 rounded-lg border p-4 text-center ${color}`}>
|
||||||
|
<div className="text-2xl font-bold">{n}</div>
|
||||||
|
<div className="text-xs">{label}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupByTitle(items: Mitigation[]): Array<{ title: string; instances: Mitigation[] }> {
|
||||||
|
const map = new Map<string, Mitigation[]>()
|
||||||
|
for (const m of items) {
|
||||||
|
const key = (m.title || '').trim() || '(ohne Titel)'
|
||||||
|
const arr = map.get(key)
|
||||||
|
if (arr) arr.push(m); else map.set(key, [m])
|
||||||
|
}
|
||||||
|
// Frontend dedupe per hazard_id (mirrors mitigations/page.tsx)
|
||||||
|
return Array.from(map.entries()).map(([title, list]) => {
|
||||||
|
const byHazard = new Map<string, Mitigation>()
|
||||||
|
for (const m of list) {
|
||||||
|
const hid = (m.linked_hazard_ids || []).join('|') || m.id
|
||||||
|
const prev = byHazard.get(hid)
|
||||||
|
if (!prev || (m.status === 'verified' && prev.status !== 'verified')) byHazard.set(hid, m)
|
||||||
|
}
|
||||||
|
return { title, instances: Array.from(byHazard.values()) }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user