Files
breakpilot-compliance/admin-compliance/app/sdk/iace/[projectId]/mitigations/page.tsx
T
Benjamin Admin 89af88ef7d feat: Fortschritts-Tracker + Verifikation-Endpoints + Tech-File Erweiterung
- Übersicht: Completeness Gates durch Projektfortschritts-Tracker ersetzt
  (6 CE-Prozessschritte mit Status + Naechster-Schritt Empfehlung)
- Verifikation: GET/POST/DELETE /verifications Endpoints + Alias-Handler
- Tech-File: Anhang IV Struktur-Erweiterung
- Maßnahmen: Expandable Details vorbereitet

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-08 01:02:41 +02:00

266 lines
12 KiB
TypeScript

'use client'
import { useState, useEffect } from 'react'
import { useParams } from 'next/navigation'
import { REDUCTION_TYPES, Mitigation } from './_components/types'
import { HierarchyWarning } from './_components/HierarchyWarning'
import { MeasuresLibraryModal } from './_components/MeasuresLibraryModal'
import { SuggestMeasuresModal } from './_components/SuggestMeasuresModal'
import { MitigationForm } from './_components/MitigationForm'
import { StatusBadge } from './_components/StatusBadge'
import { ProtectiveMeasure } from './_components/types'
import { useMitigations } from './_hooks/useMitigations'
export default function MitigationsPage() {
const params = useParams()
const projectId = params.projectId as string
const {
hazards, loading, hierarchyWarning, setHierarchyWarning,
measures, byType,
fetchMeasuresLibrary, handleSubmit, handleAddSuggestedMeasure, handleVerify, handleDelete,
} = useMitigations(projectId)
const [measureNorms, setMeasureNorms] = useState<Record<string, string[]>>({})
useEffect(() => {
fetch('/api/sdk/v1/iace/protective-measures-library')
.then(r => r.ok ? r.json() : null)
.then(json => {
if (!json?.protective_measures) return
const map: Record<string, string[]> = {}
for (const m of json.protective_measures) {
if (m.norm_references?.length > 0) {
map[(m.name || '').toLowerCase()] = m.norm_references
}
}
setMeasureNorms(map)
})
.catch(() => {})
}, [])
const [showForm, setShowForm] = useState(false)
const [preselectedType, setPreselectedType] = useState<'design' | 'protection' | 'information' | undefined>()
const [showLibrary, setShowLibrary] = useState(false)
const [libraryFilter, setLibraryFilter] = useState<string | undefined>()
const [showSuggest, setShowSuggest] = useState(false)
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 [selected, setSelected] = useState<Set<string>>(new Set())
const [batchAction, setBatchAction] = useState<'verify' | 'delete' | null>(null)
const [expandedMeasure, setExpandedMeasure] = useState<string | null>(null)
function toggleSection(type: string) {
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)
setShowLibrary(true)
}
function handleSelectMeasure(measure: ProtectiveMeasure) {
setShowLibrary(false)
setShowForm(true)
setPreselectedType(measure.reduction_type as 'design' | 'protection' | 'information')
}
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>
)
}
const totalMeasures = byType.design.length + byType.protection.length + byType.information.length
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Massnahmen</h1>
<p className="mt-1 text-sm text-gray-500">
{totalMeasures} Massnahmen nach 3-Stufen-Verfahren: Design ({byType.design.length}) &rarr; Schutz ({byType.protection.length}) &rarr; Information ({byType.information.length})
</p>
</div>
<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)}
className="px-3 py-1.5 text-xs border border-green-300 text-green-700 rounded-lg hover:bg-green-50">
Vorschlaege
</button>
<button onClick={() => handleOpenLibrary()}
className="px-3 py-1.5 text-xs border border-purple-300 text-purple-700 rounded-lg hover:bg-purple-50">
Bibliothek
</button>
<button onClick={() => { setPreselectedType(undefined); setShowForm(true) }}
className="px-3 py-1.5 text-xs bg-purple-600 text-white rounded-lg hover:bg-purple-700">
+ Hinzufuegen
</button>
</>
)}
</div>
</div>
{hierarchyWarning && <HierarchyWarning onDismiss={() => setHierarchyWarning(false)} />}
{showForm && (
<MitigationForm
onSubmit={async (data) => { const ok = await handleSubmit(data); if (ok) setShowForm(false) }}
onCancel={() => setShowForm(false)} hazards={hazards} preselectedType={preselectedType} onOpenLibrary={handleOpenLibrary}
/>
)}
{showLibrary && <MeasuresLibraryModal measures={measures} onSelect={handleSelectMeasure} onClose={() => setShowLibrary(false)} filterType={libraryFilter} />}
{showSuggest && <SuggestMeasuresModal hazards={hazards} projectId={projectId} onAddMeasure={handleAddSuggestedMeasure} onClose={() => setShowSuggest(false)} />}
{/* 3-Step Accordions */}
{(['design', 'protection', 'information'] as const).map((type) => {
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 (
<div key={type} className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
{/* Accordion Header */}
<button onClick={() => toggleSection(type)}
className={`w-full flex items-center gap-3 px-4 py-3 text-left transition-colors ${config.headerColor}`}>
<svg className={`w-4 h-4 transition-transform ${isExpanded ? 'rotate-90' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
{config.icon}
<div className="flex-1">
<span className="text-sm font-semibold">{config.label}</span>
<span className="ml-2 text-xs opacity-75">{config.description}</span>
</div>
<span className="text-sm font-bold">{items.length}</span>
</button>
{/* Accordion Content — Table rows */}
{isExpanded && items.length > 0 && (
<div className="border-t border-gray-100 dark:border-gray-700">
{/* Table header */}
<div className="grid grid-cols-[24px_2fr_1fr_80px] gap-2 px-4 py-2 bg-gray-50 dark:bg-gray-750 text-xs font-medium text-gray-500 uppercase tracking-wider">
<div>
<input type="checkbox" checked={allSelected} onChange={() => selectAllInType(type)}
className="accent-purple-600" title="Alle auswaehlen" />
</div>
<div>Massnahme</div>
<div>Gefaehrdung</div>
<div>Status</div>
</div>
{/* Rows — paginated */}
{items.slice(0, (mitPages[type] || 1) * 50).map((m) => {
const isDetailOpen = expandedMeasure === m.id
const catMatch = (m.description || '').match(/Kategorie\s+(\S+)/)
const category = catMatch?.[1]
const refs = measureNorms[(m.title || '').toLowerCase()]
return (
<div key={m.id}>
<div onClick={() => setExpandedMeasure(isDetailOpen ? null : m.id)}
className={`grid grid-cols-[24px_2fr_1fr_80px] 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 ${selected.has(m.id) ? 'bg-purple-50 dark:bg-purple-900/10' : ''}`}>
<div className="pt-0.5" onClick={(e) => e.stopPropagation()}>
<input type="checkbox" checked={selected.has(m.id)} onChange={() => toggleSelect(m.id)}
className="accent-purple-600" />
</div>
<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 ${isDetailOpen ? 'rotate-90' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
<div>
<div className="text-sm text-gray-900 dark:text-white">{m.title || ''}</div>
{!isDetailOpen && category && <div className="text-[10px] text-gray-400 mt-0.5">Kategorie: {category}</div>}
</div>
</div>
<div className="text-xs text-gray-500">
{(m.linked_hazard_names || []).join(', ') || '-'}
</div>
<div>
<StatusBadge status={m.status} />
</div>
</div>
{isDetailOpen && (
<div className="px-12 py-3 bg-gray-50 dark:bg-gray-750 border-t border-gray-100 dark:border-gray-700 text-xs space-y-1">
{m.description && <p className="text-gray-600 dark:text-gray-300">{m.description}</p>}
{category && <p className="text-purple-600">Diese Massnahme gilt fuer alle Gefaehrdungen der Kategorie <strong>{category}</strong>.</p>}
{refs?.length > 0 && <p className="text-blue-500">Normen: {refs.join(', ')}</p>}
</div>
)}
</div>
)
})}
{items.length > (mitPages[type] || 1) * 50 && (
<button onClick={() => setMitPages(prev => ({ ...prev, [type]: (prev[type] || 1) + 1 }))}
className="w-full py-2 text-xs text-purple-600 hover:bg-purple-50 border-t border-gray-100 transition-colors">
Weitere {Math.min(50, items.length - (mitPages[type] || 1) * 50)} von {items.length} laden...
</button>
)}
</div>
)}
{isExpanded && items.length === 0 && (
<div className="px-4 py-6 text-center text-sm text-gray-400 border-t border-gray-100">
Keine Massnahmen in dieser Stufe
</div>
)}
</div>
)
})}
</div>
)
}