cd5f986489
Grid-Layout statt flex mit fixen Breiten. Texte umbrechen statt abschneiden. Gefaehrdung-Spalte 200px, Status 80px. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
220 lines
9.7 KiB
TypeScript
220 lines
9.7 KiB
TypeScript
'use client'
|
|
|
|
import { useState } 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 [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 [selected, setSelected] = useState<Set<string>>(new Set())
|
|
const [batchAction, setBatchAction] = useState<'verify' | 'delete' | 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}) → Schutz ({byType.protection.length}) → 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_1fr_200px_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 */}
|
|
{items.map((m) => (
|
|
<div key={m.id}
|
|
className={`grid grid-cols-[24px_1fr_200px_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 ${selected.has(m.id) ? 'bg-purple-50 dark:bg-purple-900/10' : ''}`}>
|
|
<div className="pt-0.5">
|
|
<input type="checkbox" checked={selected.has(m.id)} onChange={() => toggleSelect(m.id)}
|
|
className="accent-purple-600" />
|
|
</div>
|
|
<div className="min-w-0">
|
|
<div className="text-sm text-gray-900 dark:text-white">{m.title || ''}</div>
|
|
{m.description && <div className="text-xs text-gray-400 mt-0.5">{m.description}</div>}
|
|
</div>
|
|
<div className="text-xs text-gray-500">
|
|
{(m.linked_hazard_names || []).join(', ') || '-'}
|
|
</div>
|
|
<div>
|
|
<StatusBadge status={m.status} />
|
|
</div>
|
|
</div>
|
|
))}
|
|
</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>
|
|
)
|
|
}
|