dsfa/[id]/page.tsx (1893 LOC -> 350 LOC) split into 9 components: Section1-5Editor, SDMCoverageOverview, RAGSearchPanel, AddRiskModal, AddMitigationModal. Page is now a thin orchestrator. notfallplan/page.tsx (1890 LOC -> 435 LOC) split into 8 modules: types.ts, ConfigTab, IncidentsTab, TemplatesTab, ExercisesTab, Modals, ApiSections. All under the 500-line hard cap. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
206 lines
8.4 KiB
TypeScript
206 lines
8.4 KiB
TypeScript
'use client'
|
|
|
|
import React, { useState } from 'react'
|
|
import { DSFARisk } from '@/lib/sdk/dsfa/types'
|
|
import type { SDMGoal } from '@/lib/sdk/dsfa/types'
|
|
import {
|
|
MITIGATION_LIBRARY,
|
|
MITIGATION_TYPE_LABELS,
|
|
SDM_GOAL_LABELS,
|
|
EFFECTIVENESS_LABELS,
|
|
} from '@/lib/sdk/dsfa/mitigation-library'
|
|
import type { CatalogMitigation } from '@/lib/sdk/dsfa/mitigation-library'
|
|
|
|
export function AddMitigationModal({
|
|
risks,
|
|
onClose,
|
|
onAdd,
|
|
}: {
|
|
risks: DSFARisk[]
|
|
onClose: () => void
|
|
onAdd: (data: { risk_id: string; description: string; type: string; responsible_party: string }) => void
|
|
}) {
|
|
const [mode, setMode] = useState<'library' | 'manual'>('library')
|
|
const [riskId, setRiskId] = useState(risks[0]?.id || '')
|
|
const [type, setType] = useState('technical')
|
|
const [description, setDescription] = useState('')
|
|
const [responsibleParty, setResponsibleParty] = useState('')
|
|
const [typeFilter, setTypeFilter] = useState<'all' | 'technical' | 'organizational' | 'legal'>('all')
|
|
const [sdmFilter, setSdmFilter] = useState<SDMGoal | 'all'>('all')
|
|
|
|
const filteredLibrary = MITIGATION_LIBRARY.filter(m => {
|
|
if (typeFilter !== 'all' && m.type !== typeFilter) return false
|
|
if (sdmFilter !== 'all' && !m.sdmGoals.includes(sdmFilter)) return false
|
|
return true
|
|
})
|
|
|
|
function selectCatalogMitigation(m: CatalogMitigation) {
|
|
setType(m.type)
|
|
setDescription(`${m.title}\n\n${m.description}\n\nRechtsgrundlage: ${m.legalBasis}`)
|
|
setMode('manual')
|
|
}
|
|
|
|
return (
|
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
|
<div className="bg-white rounded-xl p-6 max-w-2xl w-full mx-4 max-h-[85vh] overflow-y-auto">
|
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Massnahme hinzufuegen</h3>
|
|
|
|
{/* Tab Toggle */}
|
|
<div className="flex border-b mb-4">
|
|
<button
|
|
onClick={() => setMode('library')}
|
|
className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px ${
|
|
mode === 'library' ? 'border-purple-500 text-purple-600' : 'border-transparent text-gray-500 hover:text-gray-700'
|
|
}`}
|
|
>
|
|
Aus Bibliothek waehlen ({MITIGATION_LIBRARY.length})
|
|
</button>
|
|
<button
|
|
onClick={() => setMode('manual')}
|
|
className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px ${
|
|
mode === 'manual' ? 'border-purple-500 text-purple-600' : 'border-transparent text-gray-500 hover:text-gray-700'
|
|
}`}
|
|
>
|
|
Manuell eingeben
|
|
</button>
|
|
</div>
|
|
|
|
{mode === 'library' ? (
|
|
<div className="space-y-3">
|
|
{/* Filters */}
|
|
<div className="flex gap-2">
|
|
<select
|
|
value={typeFilter}
|
|
onChange={e => setTypeFilter(e.target.value as typeof typeFilter)}
|
|
className="text-sm border rounded px-2 py-1"
|
|
>
|
|
<option value="all">Alle Typen</option>
|
|
{Object.entries(MITIGATION_TYPE_LABELS).map(([key, label]) => (
|
|
<option key={key} value={key}>{label}</option>
|
|
))}
|
|
</select>
|
|
<select
|
|
value={sdmFilter}
|
|
onChange={e => setSdmFilter(e.target.value as SDMGoal | 'all')}
|
|
className="text-sm border rounded px-2 py-1"
|
|
>
|
|
<option value="all">Alle SDM-Ziele</option>
|
|
{Object.entries(SDM_GOAL_LABELS).map(([key, label]) => (
|
|
<option key={key} value={key}>{label}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{/* Library List */}
|
|
<div className="max-h-[40vh] overflow-y-auto space-y-2">
|
|
{filteredLibrary.map(m => (
|
|
<button
|
|
key={m.id}
|
|
onClick={() => selectCatalogMitigation(m)}
|
|
className="w-full text-left p-3 rounded-lg border hover:border-purple-300 hover:bg-purple-50 transition-colors"
|
|
>
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<span className="text-xs font-mono text-gray-400">{m.id}</span>
|
|
<span className={`text-xs px-2 py-0.5 rounded-full ${
|
|
m.type === 'technical' ? 'bg-blue-50 text-blue-600' :
|
|
m.type === 'organizational' ? 'bg-green-50 text-green-600' :
|
|
'bg-purple-50 text-purple-600'
|
|
}`}>
|
|
{MITIGATION_TYPE_LABELS[m.type]}
|
|
</span>
|
|
{m.sdmGoals.map(g => (
|
|
<span key={g} className="text-xs px-2 py-0.5 rounded-full bg-gray-100 text-gray-600">
|
|
{SDM_GOAL_LABELS[g]}
|
|
</span>
|
|
))}
|
|
<span className={`text-xs px-2 py-0.5 rounded-full ${
|
|
m.effectiveness === 'high' ? 'bg-green-50 text-green-700' :
|
|
m.effectiveness === 'medium' ? 'bg-yellow-50 text-yellow-700' :
|
|
'bg-gray-50 text-gray-500'
|
|
}`}>
|
|
{EFFECTIVENESS_LABELS[m.effectiveness]}
|
|
</span>
|
|
</div>
|
|
<div className="text-sm font-medium text-gray-900">{m.title}</div>
|
|
<div className="text-xs text-gray-500 mt-1 line-clamp-2">{m.description}</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
{filteredLibrary.length === 0 && (
|
|
<p className="text-sm text-gray-500 text-center py-4">Keine Massnahmen fuer die gewaehlten Filter.</p>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">Zugehoeriges Risiko</label>
|
|
<select
|
|
value={riskId}
|
|
onChange={(e) => setRiskId(e.target.value)}
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
|
>
|
|
{risks.map(risk => (
|
|
<option key={risk.id} value={risk.id}>
|
|
{risk.description.substring(0, 50)}...
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">Typ</label>
|
|
<select
|
|
value={type}
|
|
onChange={(e) => setType(e.target.value)}
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
|
>
|
|
<option value="technical">Technisch</option>
|
|
<option value="organizational">Organisatorisch</option>
|
|
<option value="legal">Rechtlich</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">Beschreibung</label>
|
|
<textarea
|
|
value={description}
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
|
rows={3}
|
|
placeholder="Beschreiben Sie die Massnahme..."
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">Verantwortlich</label>
|
|
<input
|
|
type="text"
|
|
value={responsibleParty}
|
|
onChange={(e) => setResponsibleParty(e.target.value)}
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
|
placeholder="Name oder Rolle..."
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex gap-3 mt-6">
|
|
<button
|
|
onClick={onClose}
|
|
className="flex-1 py-2 px-4 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50"
|
|
>
|
|
Abbrechen
|
|
</button>
|
|
<button
|
|
onClick={() => onAdd({ risk_id: riskId, description, type, responsible_party: responsibleParty })}
|
|
disabled={!description.trim() || mode === 'library'}
|
|
className="flex-1 py-2 px-4 bg-purple-600 text-white rounded-lg font-medium hover:bg-purple-700 disabled:opacity-50"
|
|
>
|
|
Hinzufuegen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|