refactor(admin): split control-library, iace/mitigations, iace/components, controls pages
All 4 page.tsx files reduced well below 500 LOC (235/181/158/262) by extracting components and hooks into colocated _components/ and _hooks/ subdirectories. Zero behavior changes — logic relocated verbatim. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,394 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
Shield, Search, ChevronRight, ChevronLeft, Filter, Lock,
|
||||
BookOpen, Plus, Zap, BarChart3, ListChecks, Trash2,
|
||||
ChevronsLeft, ChevronsRight, ArrowUpDown, Clock, RefreshCw,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
CanonicalControl, Framework,
|
||||
SeverityBadge, StateBadge, LicenseRuleBadge, VerificationMethodBadge, CategoryBadge, EvidenceTypeBadge, TargetAudienceBadge,
|
||||
GenerationStrategyBadge, ObligationTypeBadge,
|
||||
VERIFICATION_METHODS, CATEGORY_OPTIONS, EVIDENCE_TYPE_OPTIONS,
|
||||
} from './helpers'
|
||||
import { ControlsMeta } from './useControlLibraryState'
|
||||
import { GeneratorModal } from './GeneratorModal'
|
||||
|
||||
interface ControlListViewProps {
|
||||
frameworks: Framework[]
|
||||
controls: CanonicalControl[]
|
||||
totalCount: number
|
||||
meta: ControlsMeta | null
|
||||
loading: boolean
|
||||
reviewCount: number
|
||||
bulkProcessing: boolean
|
||||
showStats: boolean
|
||||
processedStats: Array<Record<string, unknown>>
|
||||
showGenerator: boolean
|
||||
currentPage: number
|
||||
totalPages: number
|
||||
sortBy: 'id' | 'newest' | 'oldest' | 'source'
|
||||
// Filter values
|
||||
searchQuery: string
|
||||
severityFilter: string
|
||||
domainFilter: string
|
||||
stateFilter: string
|
||||
verificationFilter: string
|
||||
categoryFilter: string
|
||||
evidenceTypeFilter: string
|
||||
audienceFilter: string
|
||||
sourceFilter: string
|
||||
typeFilter: string
|
||||
hideDuplicates: boolean
|
||||
// Setters
|
||||
setSearchQuery: (v: string) => void
|
||||
setSeverityFilter: (v: string) => void
|
||||
setDomainFilter: (v: string) => void
|
||||
setStateFilter: (v: string) => void
|
||||
setVerificationFilter: (v: string) => void
|
||||
setCategoryFilter: (v: string) => void
|
||||
setEvidenceTypeFilter: (v: string) => void
|
||||
setAudienceFilter: (v: string) => void
|
||||
setSourceFilter: (v: string) => void
|
||||
setTypeFilter: (v: string) => void
|
||||
setHideDuplicates: (v: boolean) => void
|
||||
setSortBy: (v: 'id' | 'newest' | 'oldest' | 'source') => void
|
||||
setShowStats: (v: boolean) => void
|
||||
setShowGenerator: (v: boolean) => void
|
||||
setCurrentPage: (v: number | ((p: number) => number)) => void
|
||||
// Actions
|
||||
onSelectControl: (c: CanonicalControl) => void
|
||||
onCreateMode: () => void
|
||||
onEnterReview: () => void
|
||||
onBulkReject: (state: string) => void
|
||||
onRefresh: () => void
|
||||
onLoadStats: () => void
|
||||
onFullReload: () => void
|
||||
}
|
||||
|
||||
export function ControlListView({
|
||||
frameworks, controls, totalCount, meta, loading,
|
||||
reviewCount, bulkProcessing, showStats, processedStats,
|
||||
showGenerator, currentPage, totalPages, sortBy,
|
||||
searchQuery, severityFilter, domainFilter, stateFilter,
|
||||
verificationFilter, categoryFilter, evidenceTypeFilter, audienceFilter,
|
||||
sourceFilter, typeFilter, hideDuplicates,
|
||||
setSearchQuery, setSeverityFilter, setDomainFilter, setStateFilter,
|
||||
setVerificationFilter, setCategoryFilter, setEvidenceTypeFilter, setAudienceFilter,
|
||||
setSourceFilter, setTypeFilter, setHideDuplicates, setSortBy,
|
||||
setShowStats, setShowGenerator, setCurrentPage,
|
||||
onSelectControl, onCreateMode, onEnterReview, onBulkReject, onRefresh, onLoadStats, onFullReload,
|
||||
}: ControlListViewProps) {
|
||||
const debouncedSearch = searchQuery // used for empty state message
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="border-b border-gray-200 bg-white px-6 py-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Shield className="w-6 h-6 text-purple-600" />
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-gray-900">Canonical Control Library</h1>
|
||||
<p className="text-xs text-gray-500">{meta?.total ?? totalCount} Security Controls</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{reviewCount > 0 && (
|
||||
<>
|
||||
<button onClick={onEnterReview} className="flex items-center gap-1.5 px-3 py-2 text-sm text-white bg-yellow-600 rounded-lg hover:bg-yellow-700">
|
||||
<ListChecks className="w-4 h-4" />
|
||||
Review ({reviewCount})
|
||||
</button>
|
||||
<button onClick={() => onBulkReject('needs_review')} disabled={bulkProcessing}
|
||||
className="flex items-center gap-1.5 px-3 py-2 text-sm text-white bg-red-600 rounded-lg hover:bg-red-700 disabled:opacity-50">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
{bulkProcessing ? 'Wird verarbeitet...' : `Alle ${reviewCount} ablehnen`}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button onClick={() => { setShowStats(!showStats); if (!showStats) onLoadStats() }}
|
||||
className="flex items-center gap-1.5 px-3 py-2 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50">
|
||||
<BarChart3 className="w-4 h-4" />Stats
|
||||
</button>
|
||||
<button onClick={() => setShowGenerator(true)}
|
||||
className="flex items-center gap-1.5 px-3 py-2 text-sm text-white bg-amber-600 rounded-lg hover:bg-amber-700">
|
||||
<Zap className="w-4 h-4" />Generator
|
||||
</button>
|
||||
<button onClick={onCreateMode}
|
||||
className="flex items-center gap-1.5 px-3 py-2 text-sm text-white bg-purple-600 rounded-lg hover:bg-purple-700">
|
||||
<Plus className="w-4 h-4" />Neues Control
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{frameworks.length > 0 && (
|
||||
<div className="mb-4 p-3 bg-purple-50 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-xs text-purple-700">
|
||||
<Lock className="w-3 h-3" />
|
||||
<span className="font-medium">{frameworks[0]?.name} v{frameworks[0]?.version}</span>
|
||||
<span className="text-purple-500">—</span>
|
||||
<span>{frameworks[0]?.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input type="text" placeholder="Controls durchsuchen (ID, Titel, Objective)..."
|
||||
value={searchQuery} onChange={e => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-9 pr-4 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500" />
|
||||
</div>
|
||||
<button onClick={onRefresh} className="p-2 text-gray-400 hover:text-purple-600" title="Aktualisieren">
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Filter className="w-4 h-4 text-gray-400" />
|
||||
<select value={severityFilter} onChange={e => setSeverityFilter(e.target.value)}
|
||||
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-purple-500">
|
||||
<option value="">Schweregrad</option>
|
||||
<option value="critical">Kritisch{meta?.severity_counts?.critical ? ` (${meta.severity_counts.critical})` : ''}</option>
|
||||
<option value="high">Hoch{meta?.severity_counts?.high ? ` (${meta.severity_counts.high})` : ''}</option>
|
||||
<option value="medium">Mittel{meta?.severity_counts?.medium ? ` (${meta.severity_counts.medium})` : ''}</option>
|
||||
<option value="low">Niedrig{meta?.severity_counts?.low ? ` (${meta.severity_counts.low})` : ''}</option>
|
||||
</select>
|
||||
<select value={domainFilter} onChange={e => setDomainFilter(e.target.value)}
|
||||
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-purple-500">
|
||||
<option value="">Domain</option>
|
||||
{(meta?.domains || []).map(d => <option key={d.domain} value={d.domain}>{d.domain} ({d.count})</option>)}
|
||||
</select>
|
||||
<select value={stateFilter} onChange={e => setStateFilter(e.target.value)}
|
||||
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-purple-500">
|
||||
<option value="">Status</option>
|
||||
<option value="draft">Draft{meta?.release_state_counts?.draft ? ` (${meta.release_state_counts.draft})` : ''}</option>
|
||||
<option value="approved">Approved{meta?.release_state_counts?.approved ? ` (${meta.release_state_counts.approved})` : ''}</option>
|
||||
<option value="needs_review">Review noetig{meta?.release_state_counts?.needs_review ? ` (${meta.release_state_counts.needs_review})` : ''}</option>
|
||||
<option value="too_close">Zu aehnlich{meta?.release_state_counts?.too_close ? ` (${meta.release_state_counts.too_close})` : ''}</option>
|
||||
<option value="duplicate">Duplikat{meta?.release_state_counts?.duplicate ? ` (${meta.release_state_counts.duplicate})` : ''}</option>
|
||||
<option value="deprecated">Deprecated{meta?.release_state_counts?.deprecated ? ` (${meta.release_state_counts.deprecated})` : ''}</option>
|
||||
</select>
|
||||
<label className="flex items-center gap-1.5 text-sm text-gray-600 cursor-pointer whitespace-nowrap">
|
||||
<input type="checkbox" checked={hideDuplicates} onChange={e => setHideDuplicates(e.target.checked)}
|
||||
className="rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||
Duplikate ausblenden
|
||||
</label>
|
||||
<select value={verificationFilter} onChange={e => setVerificationFilter(e.target.value)}
|
||||
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-purple-500">
|
||||
<option value="">Nachweis</option>
|
||||
{Object.entries(VERIFICATION_METHODS).map(([k, v]) => (
|
||||
<option key={k} value={k}>{v.label}{meta?.verification_method_counts?.[k] ? ` (${meta.verification_method_counts[k]})` : ''}</option>
|
||||
))}
|
||||
{meta?.verification_method_counts?.['__none__'] ? <option value="__none__">Ohne Nachweis ({meta.verification_method_counts['__none__']})</option> : null}
|
||||
</select>
|
||||
<select value={categoryFilter} onChange={e => setCategoryFilter(e.target.value)}
|
||||
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-purple-500">
|
||||
<option value="">Kategorie</option>
|
||||
{CATEGORY_OPTIONS.map(c => <option key={c.value} value={c.value}>{c.label}{meta?.category_counts?.[c.value] ? ` (${meta.category_counts[c.value]})` : ''}</option>)}
|
||||
{meta?.category_counts?.['__none__'] ? <option value="__none__">Ohne Kategorie ({meta.category_counts['__none__']})</option> : null}
|
||||
</select>
|
||||
<select value={evidenceTypeFilter} onChange={e => setEvidenceTypeFilter(e.target.value)}
|
||||
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-purple-500">
|
||||
<option value="">Nachweisart</option>
|
||||
{EVIDENCE_TYPE_OPTIONS.map(c => <option key={c.value} value={c.value}>{c.label}{meta?.evidence_type_counts?.[c.value] ? ` (${meta.evidence_type_counts[c.value]})` : ''}</option>)}
|
||||
{meta?.evidence_type_counts?.['__none__'] ? <option value="__none__">Ohne Nachweisart ({meta.evidence_type_counts['__none__']})</option> : null}
|
||||
</select>
|
||||
<select value={audienceFilter} onChange={e => setAudienceFilter(e.target.value)}
|
||||
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-purple-500">
|
||||
<option value="">Zielgruppe</option>
|
||||
<option value="unternehmen">Unternehmen</option>
|
||||
<option value="behoerden">Behoerden</option>
|
||||
<option value="entwickler">Entwickler</option>
|
||||
<option value="datenschutzbeauftragte">DSB</option>
|
||||
<option value="geschaeftsfuehrung">Geschaeftsfuehrung</option>
|
||||
<option value="it-abteilung">IT-Abteilung</option>
|
||||
<option value="rechtsabteilung">Rechtsabteilung</option>
|
||||
<option value="compliance-officer">Compliance Officer</option>
|
||||
<option value="personalwesen">Personalwesen</option>
|
||||
<option value="einkauf">Einkauf</option>
|
||||
<option value="produktion">Produktion</option>
|
||||
<option value="vertrieb">Vertrieb</option>
|
||||
<option value="gesundheitswesen">Gesundheitswesen</option>
|
||||
<option value="finanzwesen">Finanzwesen</option>
|
||||
<option value="oeffentlicher_dienst">Oeffentl. Dienst</option>
|
||||
</select>
|
||||
<select value={sourceFilter} onChange={e => setSourceFilter(e.target.value)}
|
||||
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-purple-500 max-w-[220px]">
|
||||
<option value="">Dokumentenursprung</option>
|
||||
{meta && <option value="__none__">Ohne Quelle ({meta.no_source_count})</option>}
|
||||
{(meta?.sources || []).map(s => <option key={s.source} value={s.source}>{s.source} ({s.count})</option>)}
|
||||
</select>
|
||||
<select value={typeFilter} onChange={e => setTypeFilter(e.target.value)}
|
||||
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-purple-500">
|
||||
<option value="">Alle Typen</option>
|
||||
<option value="rich">Rich Controls{meta?.type_counts ? ` (${meta.type_counts.rich})` : ''}</option>
|
||||
<option value="atomic">Atomare Controls{meta?.type_counts ? ` (${meta.type_counts.atomic})` : ''}</option>
|
||||
<option value="eigenentwicklung">Eigenentwicklung{meta?.type_counts ? ` (${meta.type_counts.eigenentwicklung})` : ''}</option>
|
||||
</select>
|
||||
<span className="text-gray-300 mx-1">|</span>
|
||||
<ArrowUpDown className="w-4 h-4 text-gray-400" />
|
||||
<select value={sortBy} onChange={e => setSortBy(e.target.value as 'id' | 'newest' | 'oldest' | 'source')}
|
||||
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-purple-500">
|
||||
<option value="id">Sortierung: ID</option>
|
||||
<option value="source">Nach Quelle</option>
|
||||
<option value="newest">Neueste zuerst</option>
|
||||
<option value="oldest">Aelteste zuerst</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showStats && processedStats.length > 0 && (
|
||||
<div className="mt-3 p-3 bg-gray-50 rounded-lg">
|
||||
<h4 className="text-xs font-semibold text-gray-700 mb-2">Verarbeitungsfortschritt</h4>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{processedStats.map((s, i) => (
|
||||
<div key={i} className="text-xs">
|
||||
<span className="font-medium text-gray-700">{String(s.collection)}</span>
|
||||
<div className="flex gap-2 mt-1 text-gray-500">
|
||||
<span>{String(s.processed_chunks)} verarbeitet</span>
|
||||
<span>{String(s.direct_adopted)} direkt</span>
|
||||
<span>{String(s.llm_reformed)} reformuliert</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showGenerator && <GeneratorModal onClose={() => setShowGenerator(false)} onComplete={onFullReload} />}
|
||||
|
||||
{/* Pagination Header */}
|
||||
<div className="px-6 py-2 bg-gray-50 border-b border-gray-200 flex items-center justify-between text-xs text-gray-500">
|
||||
<div className="flex items-center gap-3">
|
||||
<span>
|
||||
{totalCount} Controls gefunden
|
||||
{totalCount !== (meta?.total ?? totalCount) && ` (von ${meta?.total} gesamt)`}
|
||||
{loading && <span className="ml-2 text-purple-500">Lade...</span>}
|
||||
</span>
|
||||
{stateFilter && ['needs_review', 'too_close', 'duplicate'].includes(stateFilter) && totalCount > 0 && (
|
||||
<button onClick={() => onBulkReject(stateFilter)} disabled={bulkProcessing}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs text-white bg-red-600 rounded hover:bg-red-700 disabled:opacity-50">
|
||||
<Trash2 className="w-3 h-3" />
|
||||
{bulkProcessing ? '...' : `Alle ${totalCount} ablehnen`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<span>Seite {currentPage} von {totalPages}</span>
|
||||
</div>
|
||||
|
||||
{/* Control List */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="space-y-3">
|
||||
{controls.map((ctrl, idx) => {
|
||||
const prevSource = idx > 0 ? (controls[idx - 1].source_citation?.source || 'Ohne Quelle') : null
|
||||
const curSource = ctrl.source_citation?.source || 'Ohne Quelle'
|
||||
const showSourceHeader = sortBy === 'source' && curSource !== prevSource
|
||||
return (
|
||||
<div key={ctrl.control_id}>
|
||||
{showSourceHeader && (
|
||||
<div className="flex items-center gap-2 pt-3 pb-1">
|
||||
<div className="h-px flex-1 bg-blue-200" />
|
||||
<span className="text-xs font-semibold text-blue-700 bg-blue-50 px-2 py-0.5 rounded whitespace-nowrap">{curSource}</span>
|
||||
<div className="h-px flex-1 bg-blue-200" />
|
||||
</div>
|
||||
)}
|
||||
<button onClick={() => onSelectControl(ctrl)}
|
||||
className="w-full text-left bg-white border border-gray-200 rounded-lg p-4 hover:border-purple-300 hover:shadow-sm transition-all group">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||
<span className="text-xs font-mono text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded">{ctrl.control_id}</span>
|
||||
<SeverityBadge severity={ctrl.severity} />
|
||||
<StateBadge state={ctrl.release_state} />
|
||||
<LicenseRuleBadge rule={ctrl.license_rule} />
|
||||
<VerificationMethodBadge method={ctrl.verification_method} />
|
||||
<CategoryBadge category={ctrl.category} />
|
||||
<EvidenceTypeBadge type={ctrl.evidence_type} />
|
||||
<TargetAudienceBadge audience={ctrl.target_audience} />
|
||||
<GenerationStrategyBadge strategy={ctrl.generation_strategy} pipelineInfo={ctrl} />
|
||||
<ObligationTypeBadge type={ctrl.generation_metadata?.obligation_type as string} />
|
||||
{ctrl.risk_score !== null && <span className="text-xs text-gray-400">Score: {ctrl.risk_score}</span>}
|
||||
</div>
|
||||
<h3 className="text-sm font-medium text-gray-900 group-hover:text-purple-700">{ctrl.title}</h3>
|
||||
<p className="text-xs text-gray-500 mt-1 line-clamp-2">{ctrl.objective}</p>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<BookOpen className="w-3 h-3 text-green-600" />
|
||||
<span className="text-xs text-green-700">{ctrl.open_anchors.length} Referenzen</span>
|
||||
{ctrl.source_citation?.source && (
|
||||
<>
|
||||
<span className="text-gray-300">|</span>
|
||||
<span className="text-xs text-blue-600">
|
||||
{ctrl.source_citation.source}
|
||||
{ctrl.source_citation.article && ` ${ctrl.source_citation.article}`}
|
||||
{ctrl.source_citation.paragraph && ` ${ctrl.source_citation.paragraph}`}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text-gray-300">|</span>
|
||||
<Clock className="w-3 h-3 text-gray-400" />
|
||||
<span className="text-xs text-gray-400" title={ctrl.created_at}>
|
||||
{ctrl.created_at ? new Date(ctrl.created_at).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' }) : '–'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="w-4 h-4 text-gray-300 group-hover:text-purple-500 flex-shrink-0 mt-1 ml-4" />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{controls.length === 0 && !loading && (
|
||||
<div className="text-center py-12 text-gray-400 text-sm">
|
||||
{totalCount === 0 && !debouncedSearch && !severityFilter && !domainFilter
|
||||
? 'Noch keine Controls vorhanden. Klicke auf "Neues Control" um zu starten.'
|
||||
: 'Keine Controls gefunden.'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination Controls */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2 mt-6 pb-4">
|
||||
<button onClick={() => setCurrentPage(1)} disabled={currentPage === 1}
|
||||
className="p-2 text-gray-500 hover:text-purple-600 disabled:opacity-30 disabled:cursor-not-allowed" title="Erste Seite">
|
||||
<ChevronsLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={() => setCurrentPage(p => Math.max(1, p - 1))} disabled={currentPage === 1}
|
||||
className="p-2 text-gray-500 hover:text-purple-600 disabled:opacity-30 disabled:cursor-not-allowed" title="Vorherige Seite">
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1)
|
||||
.filter(p => p === 1 || p === totalPages || Math.abs(p - currentPage) <= 2)
|
||||
.reduce<(number | 'dots')[]>((acc, p, i, arr) => {
|
||||
if (i > 0 && p - (arr[i - 1] as number) > 1) acc.push('dots')
|
||||
acc.push(p)
|
||||
return acc
|
||||
}, [])
|
||||
.map((p, i) =>
|
||||
p === 'dots' ? (
|
||||
<span key={`dots-${i}`} className="px-1 text-gray-400">...</span>
|
||||
) : (
|
||||
<button key={p} onClick={() => setCurrentPage(p as number)}
|
||||
className={`w-8 h-8 text-sm rounded-lg ${currentPage === p ? 'bg-purple-600 text-white' : 'text-gray-600 hover:bg-purple-50 hover:text-purple-600'}`}>
|
||||
{p}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
<button onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))} disabled={currentPage === totalPages}
|
||||
className="p-2 text-gray-500 hover:text-purple-600 disabled:opacity-30 disabled:cursor-not-allowed" title="Naechste Seite">
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={() => setCurrentPage(totalPages)} disabled={currentPage === totalPages}
|
||||
className="p-2 text-gray-500 hover:text-purple-600 disabled:opacity-30 disabled:cursor-not-allowed" title="Letzte Seite">
|
||||
<ChevronsRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
'use client'
|
||||
|
||||
import { Search, GitMerge } from 'lucide-react'
|
||||
import {
|
||||
LicenseRuleBadge, VerificationMethodBadge, type CanonicalControl,
|
||||
} from './helpers'
|
||||
|
||||
interface SimilarControl {
|
||||
control_id: string
|
||||
title: string
|
||||
severity: string
|
||||
release_state: string
|
||||
tags: string[]
|
||||
license_rule: number | null
|
||||
verification_method: string | null
|
||||
category: string | null
|
||||
similarity: number
|
||||
}
|
||||
|
||||
interface V1Match {
|
||||
matched_control_id: string
|
||||
matched_title: string
|
||||
matched_objective: string
|
||||
matched_severity: string
|
||||
matched_category: string
|
||||
matched_source: string | null
|
||||
matched_article: string | null
|
||||
matched_source_citation: Record<string, string> | null
|
||||
similarity_score: number
|
||||
match_rank: number
|
||||
match_method: string
|
||||
}
|
||||
|
||||
interface ControlSimilarControlsProps {
|
||||
ctrl: CanonicalControl
|
||||
similarControls: SimilarControl[]
|
||||
loadingSimilar: boolean
|
||||
selectedDuplicates: Set<string>
|
||||
merging: boolean
|
||||
onToggleDuplicate: (id: string) => void
|
||||
onMergeDuplicates: () => void
|
||||
}
|
||||
|
||||
export function ControlSimilarControls({
|
||||
ctrl, similarControls, loadingSimilar, selectedDuplicates, merging, onToggleDuplicate, onMergeDuplicates,
|
||||
}: ControlSimilarControlsProps) {
|
||||
return (
|
||||
<section className="bg-gray-50 border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Search className="w-4 h-4 text-gray-600" />
|
||||
<h3 className="text-sm font-semibold text-gray-800">Aehnliche Controls</h3>
|
||||
{loadingSimilar && <span className="text-xs text-gray-400">Laden...</span>}
|
||||
</div>
|
||||
|
||||
{similarControls.length > 0 ? (
|
||||
<>
|
||||
<div className="mb-3 p-2 bg-white border border-gray-100 rounded flex items-center gap-2">
|
||||
<input type="radio" checked readOnly className="text-purple-600" />
|
||||
<span className="text-sm font-medium text-purple-700">{ctrl.control_id} — {ctrl.title}</span>
|
||||
<span className="text-xs text-gray-400 ml-auto">Behalten (Haupt-Control)</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{similarControls.map(sim => (
|
||||
<div key={sim.control_id} className="p-2 bg-white border border-gray-100 rounded flex items-center gap-2">
|
||||
<input type="checkbox" checked={selectedDuplicates.has(sim.control_id)}
|
||||
onChange={() => onToggleDuplicate(sim.control_id)} className="text-red-600" />
|
||||
<span className="text-xs font-mono text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded">{sim.control_id}</span>
|
||||
<span className="text-sm text-gray-700 flex-1">{sim.title}</span>
|
||||
<span className="text-xs font-medium text-amber-600 bg-amber-50 px-1.5 py-0.5 rounded">
|
||||
{(sim.similarity * 100).toFixed(1)}%
|
||||
</span>
|
||||
<LicenseRuleBadge rule={sim.license_rule} />
|
||||
<VerificationMethodBadge method={sim.verification_method} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selectedDuplicates.size > 0 && (
|
||||
<button onClick={onMergeDuplicates} disabled={merging}
|
||||
className="mt-3 flex items-center gap-1.5 px-3 py-1.5 text-sm text-white bg-red-600 rounded-lg hover:bg-red-700 disabled:opacity-50">
|
||||
<GitMerge className="w-3.5 h-3.5" />
|
||||
{merging ? 'Zusammenfuehren...' : `${selectedDuplicates.size} Duplikat(e) zusammenfuehren`}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500">
|
||||
{loadingSimilar ? 'Suche aehnliche Controls...' : 'Keine aehnlichen Controls gefunden.'}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
'use client'
|
||||
|
||||
import { ExternalLink, Scale } from 'lucide-react'
|
||||
import type { CanonicalControl } from './helpers'
|
||||
|
||||
export function ControlSourceCitation({ ctrl }: { ctrl: CanonicalControl }) {
|
||||
if (!ctrl.source_citation) return null
|
||||
|
||||
const stype = ctrl.source_citation.source_type
|
||||
const colorSet = stype === 'law'
|
||||
? { bg: 'bg-blue-50 border-blue-200', icon: 'text-blue-600', title: 'text-blue-900', badge: 'bg-blue-100 text-blue-700' }
|
||||
: stype === 'guideline'
|
||||
? { bg: 'bg-indigo-50 border-indigo-200', icon: 'text-indigo-600', title: 'text-indigo-900', badge: 'bg-indigo-100 text-indigo-700' }
|
||||
: { bg: 'bg-teal-50 border-teal-200', icon: 'text-teal-600', title: 'text-teal-900', badge: 'bg-teal-100 text-teal-700' }
|
||||
|
||||
const sectionTitle = stype === 'law' ? 'Gesetzliche Grundlage'
|
||||
: stype === 'guideline' ? 'Behoerdliche Leitlinie'
|
||||
: 'Standard / Best Practice'
|
||||
|
||||
const badgeText = stype === 'law' ? 'Direkte gesetzliche Pflicht'
|
||||
: stype === 'guideline' ? 'Aufsichtsbehoerdliche Empfehlung'
|
||||
: stype === 'standard' || (!stype && ctrl.license_rule === 2) ? 'Freiwilliger Standard'
|
||||
: !stype && ctrl.license_rule === 1 ? 'Noch nicht klassifiziert' : null
|
||||
|
||||
return (
|
||||
<section className={`border rounded-lg p-4 ${colorSet.bg}`}>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Scale className={`w-4 h-4 ${colorSet.icon}`} />
|
||||
<h3 className={`text-sm font-semibold ${colorSet.title}`}>{sectionTitle}</h3>
|
||||
{badgeText && <span className={`text-xs px-2 py-0.5 rounded-full ${colorSet.badge}`}>{badgeText}</span>}
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-1">
|
||||
{ctrl.source_citation.source ? (
|
||||
<p className="text-sm font-medium text-blue-900 mb-1">
|
||||
{ctrl.source_citation.source}
|
||||
{ctrl.source_citation.article && ` — ${ctrl.source_citation.article}`}
|
||||
{ctrl.source_citation.paragraph && ` ${ctrl.source_citation.paragraph}`}
|
||||
</p>
|
||||
) : ctrl.generation_metadata?.source_regulation ? (
|
||||
<p className="text-sm font-medium text-blue-900 mb-1">{String(ctrl.generation_metadata.source_regulation)}</p>
|
||||
) : null}
|
||||
{ctrl.source_citation.license && <p className="text-xs text-blue-600">Lizenz: {ctrl.source_citation.license}</p>}
|
||||
{ctrl.source_citation.license_notice && <p className="text-xs text-blue-600 mt-0.5">{ctrl.source_citation.license_notice}</p>}
|
||||
</div>
|
||||
{ctrl.source_citation.url && (
|
||||
<a href={ctrl.source_citation.url} target="_blank" rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-xs text-blue-600 hover:text-blue-800 whitespace-nowrap">
|
||||
<ExternalLink className="w-3.5 h-3.5" />Quelle
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
{ctrl.source_original_text && (
|
||||
<details className="mt-3">
|
||||
<summary className="text-xs text-blue-600 cursor-pointer hover:text-blue-800">Originaltext anzeigen</summary>
|
||||
<p className="text-xs text-gray-600 mt-2 p-2 bg-white rounded border border-blue-100 leading-relaxed max-h-40 overflow-y-auto whitespace-pre-wrap">
|
||||
{ctrl.source_original_text}
|
||||
</p>
|
||||
</details>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
'use client'
|
||||
|
||||
import { Scale, Landmark, GitMerge, FileText } from 'lucide-react'
|
||||
import {
|
||||
SeverityBadge, ObligationTypeBadge, ExtractionMethodBadge,
|
||||
type CanonicalControl, type ObligationInfo, type DocumentReference, type MergedDuplicate, type RegulationSummary,
|
||||
} from './helpers'
|
||||
|
||||
interface TraceabilityData {
|
||||
control_id: string
|
||||
title: string
|
||||
is_atomic: boolean
|
||||
parent_links: Array<{
|
||||
parent_control_id: string
|
||||
parent_title: string
|
||||
link_type: string
|
||||
confidence: number
|
||||
source_regulation: string | null
|
||||
source_article: string | null
|
||||
parent_citation: Record<string, string> | null
|
||||
obligation: {
|
||||
text: string; action: string; object: string; normative_strength: string
|
||||
} | null
|
||||
}>
|
||||
children: Array<{
|
||||
control_id: string; title: string; category: string; severity: string; decomposition_method: string
|
||||
}>
|
||||
source_count: number
|
||||
obligations?: ObligationInfo[]
|
||||
obligation_count?: number
|
||||
document_references?: DocumentReference[]
|
||||
merged_duplicates?: MergedDuplicate[]
|
||||
merged_duplicates_count?: number
|
||||
regulations_summary?: RegulationSummary[]
|
||||
}
|
||||
|
||||
interface ControlTraceabilityProps {
|
||||
ctrl: CanonicalControl
|
||||
traceability: TraceabilityData | null
|
||||
loadingTrace: boolean
|
||||
onNavigateToControl?: (controlId: string) => void
|
||||
}
|
||||
|
||||
export function ControlTraceability({ ctrl, traceability, loadingTrace, onNavigateToControl }: ControlTraceabilityProps) {
|
||||
const ControlLink = ({ controlId }: { controlId: string }) => {
|
||||
if (onNavigateToControl) {
|
||||
return (
|
||||
<button onClick={() => onNavigateToControl(controlId)}
|
||||
className="font-mono text-xs text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded hover:bg-purple-100 hover:underline">
|
||||
{controlId}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
return <span className="font-mono text-xs text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded">{controlId}</span>
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Regulatorische Abdeckung (Eigenentwicklung) covered by parent */}
|
||||
|
||||
{/* Rechtsgrundlagen / Traceability */}
|
||||
{traceability && traceability.parent_links.length > 0 && (
|
||||
<section className="bg-violet-50 border border-violet-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Landmark className="w-4 h-4 text-violet-600" />
|
||||
<h3 className="text-sm font-semibold text-violet-900">
|
||||
Rechtsgrundlagen ({traceability.source_count} {traceability.source_count === 1 ? 'Quelle' : 'Quellen'})
|
||||
</h3>
|
||||
<ObligationTypeBadge type={ctrl.generation_metadata?.obligation_type as string} />
|
||||
{traceability.regulations_summary?.map(rs => (
|
||||
<span key={rs.regulation_code} className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-violet-200 text-violet-800">
|
||||
{rs.regulation_code}
|
||||
</span>
|
||||
))}
|
||||
{loadingTrace && <span className="text-xs text-violet-400">Laden...</span>}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{traceability.parent_links.map((link, i) => (
|
||||
<div key={i} className="bg-white/60 border border-violet-100 rounded-lg p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<Scale className="w-4 h-4 text-violet-500 mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{link.source_regulation && <span className="text-sm font-semibold text-violet-900">{link.source_regulation}</span>}
|
||||
{link.source_article && <span className="text-sm text-violet-700">{link.source_article}</span>}
|
||||
{!link.source_regulation && link.parent_citation?.source && (
|
||||
<span className="text-sm font-semibold text-violet-900">
|
||||
{link.parent_citation.source}{link.parent_citation.article && ` — ${link.parent_citation.article}`}
|
||||
</span>
|
||||
)}
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded ${
|
||||
link.link_type === 'decomposition' ? 'bg-violet-100 text-violet-600' :
|
||||
link.link_type === 'dedup_merge' ? 'bg-blue-100 text-blue-600' : 'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{link.link_type === 'decomposition' ? 'Ableitung' : link.link_type === 'dedup_merge' ? 'Dedup' : link.link_type}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-violet-600 mt-1">
|
||||
via <ControlLink controlId={link.parent_control_id} />
|
||||
{link.parent_title && <span className="text-violet-500 ml-1">— {link.parent_title}</span>}
|
||||
</p>
|
||||
{link.obligation && (
|
||||
<p className="text-xs text-violet-500 mt-1.5 bg-violet-50 rounded p-2">
|
||||
<span className={`inline-block mr-1.5 px-1.5 py-0.5 rounded text-xs font-medium ${
|
||||
link.obligation.normative_strength === 'must' ? 'bg-red-100 text-red-700' :
|
||||
link.obligation.normative_strength === 'should' ? 'bg-amber-100 text-amber-700' : 'bg-green-100 text-green-700'
|
||||
}`}>
|
||||
{link.obligation.normative_strength === 'must' ? 'MUSS' : link.obligation.normative_strength === 'should' ? 'SOLL' : 'KANN'}
|
||||
</span>
|
||||
{link.obligation.text.slice(0, 200)}{link.obligation.text.length > 200 ? '...' : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Fallback: simple parent display */}
|
||||
{ctrl.parent_control_uuid && (!traceability || traceability.parent_links.length === 0) && !loadingTrace && (
|
||||
<section className="bg-violet-50 border border-violet-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<GitMerge className="w-4 h-4 text-violet-600" />
|
||||
<h3 className="text-sm font-semibold text-violet-900">Atomares Control</h3>
|
||||
<ObligationTypeBadge type={ctrl.generation_metadata?.obligation_type as string} />
|
||||
</div>
|
||||
<p className="text-sm text-violet-800">
|
||||
Abgeleitet aus Eltern-Control{' '}
|
||||
<span className="font-mono font-semibold text-purple-700 bg-purple-100 px-1.5 py-0.5 rounded">
|
||||
{ctrl.parent_control_id || ctrl.parent_control_uuid}
|
||||
</span>
|
||||
{ctrl.parent_control_title && <span className="text-violet-700 ml-1">— {ctrl.parent_control_title}</span>}
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Document References */}
|
||||
{traceability?.is_atomic && traceability.document_references && traceability.document_references.length > 0 && (
|
||||
<section className="bg-indigo-50 border border-indigo-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<FileText className="w-4 h-4 text-indigo-600" />
|
||||
<h3 className="text-sm font-semibold text-indigo-900">Original-Dokumente ({traceability.document_references.length})</h3>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{traceability.document_references.map((dr, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-sm bg-white/60 border border-indigo-100 rounded-lg p-2">
|
||||
<span className="font-semibold text-indigo-900">{dr.regulation_code}</span>
|
||||
{dr.article && <span className="text-indigo-700">{dr.article}</span>}
|
||||
{dr.paragraph && <span className="text-indigo-600 text-xs">{dr.paragraph}</span>}
|
||||
<span className="ml-auto flex items-center gap-1.5">
|
||||
<ExtractionMethodBadge method={dr.extraction_method} />
|
||||
{dr.confidence !== null && <span className="text-xs text-gray-500">{(dr.confidence * 100).toFixed(0)}%</span>}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Obligations */}
|
||||
{traceability && !traceability.is_atomic && traceability.obligations && traceability.obligations.length > 0 && (
|
||||
<section className="bg-amber-50 border border-amber-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Scale className="w-4 h-4 text-amber-600" />
|
||||
<h3 className="text-sm font-semibold text-amber-900">
|
||||
Abgeleitete Pflichten ({traceability.obligation_count ?? traceability.obligations.length})
|
||||
</h3>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{traceability.obligations.map((ob) => (
|
||||
<div key={ob.candidate_id} className="bg-white/60 border border-amber-100 rounded-lg p-3">
|
||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||
<span className="font-mono text-xs text-amber-700 bg-amber-100 px-1.5 py-0.5 rounded">{ob.candidate_id}</span>
|
||||
<span className={`inline-block px-1.5 py-0.5 rounded text-xs font-medium ${
|
||||
ob.normative_strength === 'must' ? 'bg-red-100 text-red-700' :
|
||||
ob.normative_strength === 'should' ? 'bg-amber-100 text-amber-700' : 'bg-green-100 text-green-700'
|
||||
}`}>
|
||||
{ob.normative_strength === 'must' ? 'MUSS' : ob.normative_strength === 'should' ? 'SOLL' : 'KANN'}
|
||||
</span>
|
||||
{ob.action && <span className="text-xs text-amber-600">{ob.action}</span>}
|
||||
{ob.object && <span className="text-xs text-amber-500">→ {ob.object}</span>}
|
||||
</div>
|
||||
<p className="text-xs text-gray-700 leading-relaxed">
|
||||
{ob.obligation_text.slice(0, 300)}{ob.obligation_text.length > 300 ? '...' : ''}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Merged Duplicates */}
|
||||
{traceability?.merged_duplicates && traceability.merged_duplicates.length > 0 && (
|
||||
<section className="bg-slate-50 border border-slate-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<GitMerge className="w-4 h-4 text-slate-600" />
|
||||
<h3 className="text-sm font-semibold text-slate-900">
|
||||
Zusammengefuehrte Duplikate ({traceability.merged_duplicates_count ?? traceability.merged_duplicates.length})
|
||||
</h3>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{traceability.merged_duplicates.map((dup) => (
|
||||
<div key={dup.control_id} className="flex items-center gap-2 text-sm">
|
||||
<ControlLink controlId={dup.control_id} />
|
||||
<span className="text-gray-700 flex-1 truncate">{dup.title}</span>
|
||||
{dup.source_regulation && <span className="text-xs text-slate-500 bg-slate-100 px-1.5 py-0.5 rounded">{dup.source_regulation}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Child controls */}
|
||||
{traceability && traceability.children.length > 0 && (
|
||||
<section className="bg-emerald-50 border border-emerald-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<GitMerge className="w-4 h-4 text-emerald-600" />
|
||||
<h3 className="text-sm font-semibold text-emerald-900">Abgeleitete Controls ({traceability.children.length})</h3>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{traceability.children.map((child) => (
|
||||
<div key={child.control_id} className="flex items-center gap-2 text-sm">
|
||||
<ControlLink controlId={child.control_id} />
|
||||
<span className="text-gray-700 flex-1 truncate">{child.title}</span>
|
||||
<SeverityBadge severity={child.severity} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
'use client'
|
||||
|
||||
import { CanonicalControl, BACKEND_URL } from './helpers'
|
||||
|
||||
export interface ControlFormData {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface CRUDDeps {
|
||||
selectedControl: CanonicalControl | null
|
||||
fullReload: () => Promise<void>
|
||||
reviewMode: boolean
|
||||
reviewIndex: number
|
||||
reviewItems: CanonicalControl[]
|
||||
setMode: (m: 'list' | 'detail' | 'create' | 'edit') => void
|
||||
setSelectedControl: (c: CanonicalControl | null) => void
|
||||
setReviewMode: (v: boolean) => void
|
||||
setReviewItems: (items: CanonicalControl[]) => void
|
||||
setReviewIndex: (i: number) => void
|
||||
setSaving: (v: boolean) => void
|
||||
setBulkProcessing: (v: boolean) => void
|
||||
reviewCount: number
|
||||
totalCount: number
|
||||
stateFilter: string
|
||||
}
|
||||
|
||||
export function createCRUDHandlers(deps: CRUDDeps) {
|
||||
const {
|
||||
selectedControl, fullReload, reviewMode, reviewIndex, reviewItems,
|
||||
setMode, setSelectedControl, setReviewMode, setReviewItems, setReviewIndex,
|
||||
setSaving, setBulkProcessing, reviewCount, totalCount, stateFilter,
|
||||
} = deps
|
||||
|
||||
const handleCreate = async (data: ControlFormData) => {
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}?endpoint=create-control`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
if (!res.ok) { const err = await res.json(); alert(`Fehler: ${err.error || err.details || 'Unbekannt'}`); return }
|
||||
await fullReload()
|
||||
setMode('list')
|
||||
} catch { alert('Netzwerkfehler') } finally { setSaving(false) }
|
||||
}
|
||||
|
||||
const handleUpdate = async (data: ControlFormData) => {
|
||||
if (!selectedControl) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}?endpoint=update-control&id=${selectedControl.control_id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
if (!res.ok) { const err = await res.json(); alert(`Fehler: ${err.error || err.details || 'Unbekannt'}`); return }
|
||||
await fullReload()
|
||||
setSelectedControl(null)
|
||||
setMode('list')
|
||||
} catch { alert('Netzwerkfehler') } finally { setSaving(false) }
|
||||
}
|
||||
|
||||
const handleDelete = async (controlId: string) => {
|
||||
if (!confirm(`Control ${controlId} wirklich loeschen?`)) return
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}?id=${controlId}`, { method: 'DELETE' })
|
||||
if (!res.ok && res.status !== 204) { alert('Fehler beim Loeschen'); return }
|
||||
await fullReload()
|
||||
setSelectedControl(null)
|
||||
setMode('list')
|
||||
} catch { alert('Netzwerkfehler') }
|
||||
}
|
||||
|
||||
const handleReview = async (controlId: string, action: string) => {
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}?endpoint=review&id=${controlId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action }),
|
||||
})
|
||||
if (res.ok) {
|
||||
await fullReload()
|
||||
if (reviewMode) {
|
||||
const remaining = reviewItems.filter(c => c.control_id !== controlId)
|
||||
setReviewItems(remaining)
|
||||
if (remaining.length > 0) {
|
||||
const nextIdx = Math.min(reviewIndex, remaining.length - 1)
|
||||
setReviewIndex(nextIdx)
|
||||
setSelectedControl(remaining[nextIdx])
|
||||
} else { setReviewMode(false); setSelectedControl(null); setMode('list') }
|
||||
} else { setSelectedControl(null); setMode('list') }
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const handleBulkReject = async (sourceState: string) => {
|
||||
const count = stateFilter === sourceState ? totalCount : reviewCount
|
||||
if (!confirm(`Alle ${count} Controls mit Status "${sourceState}" auf "deprecated" setzen? Diese Aktion kann nicht rueckgaengig gemacht werden.`)) return
|
||||
setBulkProcessing(true)
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}?endpoint=bulk-review`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ release_state: sourceState, action: 'reject' }),
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
alert(`${data.affected_count} Controls auf "deprecated" gesetzt.`)
|
||||
await fullReload()
|
||||
} else { const err = await res.json(); alert(`Fehler: ${err.error || err.details || 'Unbekannt'}`) }
|
||||
} catch { alert('Netzwerkfehler') } finally { setBulkProcessing(false) }
|
||||
}
|
||||
|
||||
return { handleCreate, handleUpdate, handleDelete, handleReview, handleBulkReject }
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { CanonicalControl, Framework, BACKEND_URL } from './helpers'
|
||||
|
||||
export interface ControlsMeta {
|
||||
total: number
|
||||
domains: Array<{ domain: string; count: number }>
|
||||
sources: Array<{ source: string; count: number }>
|
||||
no_source_count: number
|
||||
type_counts?: { rich: number; atomic: number; eigenentwicklung: number }
|
||||
severity_counts?: Record<string, number>
|
||||
verification_method_counts?: Record<string, number>
|
||||
category_counts?: Record<string, number>
|
||||
evidence_type_counts?: Record<string, number>
|
||||
release_state_counts?: Record<string, number>
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 50
|
||||
|
||||
export function useControlLibraryState() {
|
||||
const [frameworks, setFrameworks] = useState<Framework[]>([])
|
||||
const [controls, setControls] = useState<CanonicalControl[]>([])
|
||||
const [totalCount, setTotalCount] = useState(0)
|
||||
const [meta, setMeta] = useState<ControlsMeta | null>(null)
|
||||
const [selectedControl, setSelectedControl] = useState<CanonicalControl | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Filters
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('')
|
||||
const [severityFilter, setSeverityFilter] = useState<string>('')
|
||||
const [domainFilter, setDomainFilter] = useState<string>('')
|
||||
const [stateFilter, setStateFilter] = useState<string>('')
|
||||
const [verificationFilter, setVerificationFilter] = useState<string>('')
|
||||
const [categoryFilter, setCategoryFilter] = useState<string>('')
|
||||
const [evidenceTypeFilter, setEvidenceTypeFilter] = useState<string>('')
|
||||
const [audienceFilter, setAudienceFilter] = useState<string>('')
|
||||
const [sourceFilter, setSourceFilter] = useState<string>('')
|
||||
const [typeFilter, setTypeFilter] = useState<string>('')
|
||||
const [hideDuplicates, setHideDuplicates] = useState(true)
|
||||
const [sortBy, setSortBy] = useState<'id' | 'newest' | 'oldest' | 'source'>('id')
|
||||
|
||||
// CRUD / UI state
|
||||
const [mode, setMode] = useState<'list' | 'detail' | 'create' | 'edit'>('list')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [showGenerator, setShowGenerator] = useState(false)
|
||||
const [processedStats, setProcessedStats] = useState<Array<Record<string, unknown>>>([])
|
||||
const [showStats, setShowStats] = useState(false)
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [bulkProcessing, setBulkProcessing] = useState(false)
|
||||
|
||||
// Review state
|
||||
const [reviewMode, setReviewMode] = useState(false)
|
||||
const [reviewIndex, setReviewIndex] = useState(0)
|
||||
const [reviewItems, setReviewItems] = useState<CanonicalControl[]>([])
|
||||
const [reviewCount, setReviewCount] = useState(0)
|
||||
const [reviewTab, setReviewTab] = useState<'duplicates' | 'rule3'>('duplicates')
|
||||
const [reviewDuplicates, setReviewDuplicates] = useState<CanonicalControl[]>([])
|
||||
const [reviewRule3, setReviewRule3] = useState<CanonicalControl[]>([])
|
||||
|
||||
// V1 Compare state
|
||||
const [compareMode, setCompareMode] = useState(false)
|
||||
const [compareV1Control, setCompareV1Control] = useState<CanonicalControl | null>(null)
|
||||
const [compareMatches, setCompareMatches] = useState<Array<{
|
||||
matched_control_id: string; matched_title: string; matched_objective: string
|
||||
matched_severity: string; matched_category: string
|
||||
matched_source: string | null; matched_article: string | null
|
||||
matched_source_citation: Record<string, string> | null
|
||||
similarity_score: number; match_rank: number; match_method: string
|
||||
}>>([])
|
||||
|
||||
const metaAbortRef = useRef<AbortController | null>(null)
|
||||
const controlsAbortRef = useRef<AbortController | null>(null)
|
||||
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (searchTimer.current) clearTimeout(searchTimer.current)
|
||||
searchTimer.current = setTimeout(() => setDebouncedSearch(searchQuery), 400)
|
||||
return () => { if (searchTimer.current) clearTimeout(searchTimer.current) }
|
||||
}, [searchQuery])
|
||||
|
||||
const buildParams = useCallback((extra?: Record<string, string>) => {
|
||||
const p = new URLSearchParams()
|
||||
if (severityFilter) p.set('severity', severityFilter)
|
||||
if (domainFilter) p.set('domain', domainFilter)
|
||||
if (stateFilter) p.set('release_state', stateFilter)
|
||||
if (verificationFilter) p.set('verification_method', verificationFilter)
|
||||
if (categoryFilter) p.set('category', categoryFilter)
|
||||
if (evidenceTypeFilter) p.set('evidence_type', evidenceTypeFilter)
|
||||
if (audienceFilter) p.set('target_audience', audienceFilter)
|
||||
if (sourceFilter) p.set('source', sourceFilter)
|
||||
if (typeFilter) p.set('control_type', typeFilter)
|
||||
if (hideDuplicates) p.set('exclude_duplicates', 'true')
|
||||
if (debouncedSearch) p.set('search', debouncedSearch)
|
||||
if (extra) for (const [k, v] of Object.entries(extra)) p.set(k, v)
|
||||
return p.toString()
|
||||
}, [severityFilter, domainFilter, stateFilter, verificationFilter, categoryFilter, evidenceTypeFilter, audienceFilter, sourceFilter, typeFilter, hideDuplicates, debouncedSearch])
|
||||
|
||||
const loadFrameworks = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}?endpoint=frameworks`)
|
||||
if (res.ok) setFrameworks(await res.json())
|
||||
} catch { /* ignore */ }
|
||||
}, [])
|
||||
|
||||
const loadMeta = useCallback(async () => {
|
||||
if (metaAbortRef.current) metaAbortRef.current.abort()
|
||||
const controller = new AbortController()
|
||||
metaAbortRef.current = controller
|
||||
try {
|
||||
const qs = buildParams()
|
||||
const res = await fetch(`${BACKEND_URL}?endpoint=controls-meta${qs ? `&${qs}` : ''}`, { signal: controller.signal })
|
||||
if (res.ok && !controller.signal.aborted) setMeta(await res.json())
|
||||
} catch (e) {
|
||||
if (e instanceof DOMException && e.name === 'AbortError') return
|
||||
}
|
||||
}, [buildParams])
|
||||
|
||||
const loadControls = useCallback(async () => {
|
||||
if (controlsAbortRef.current) controlsAbortRef.current.abort()
|
||||
const controller = new AbortController()
|
||||
controlsAbortRef.current = controller
|
||||
try {
|
||||
setLoading(true)
|
||||
const sortField = sortBy === 'id' ? 'control_id' : sortBy === 'source' ? 'source' : 'created_at'
|
||||
const sortOrder = sortBy === 'newest' ? 'desc' : sortBy === 'oldest' ? 'asc' : 'asc'
|
||||
const offset = (currentPage - 1) * PAGE_SIZE
|
||||
const qs = buildParams({ sort: sortField, order: sortOrder, limit: String(PAGE_SIZE), offset: String(offset) })
|
||||
const countQs = buildParams()
|
||||
const [ctrlRes, countRes] = await Promise.all([
|
||||
fetch(`${BACKEND_URL}?endpoint=controls&${qs}`, { signal: controller.signal }),
|
||||
fetch(`${BACKEND_URL}?endpoint=controls-count&${countQs}`, { signal: controller.signal }),
|
||||
])
|
||||
if (!controller.signal.aborted) {
|
||||
if (ctrlRes.ok) setControls(await ctrlRes.json())
|
||||
if (countRes.ok) { const data = await countRes.json(); setTotalCount(data.total || 0) }
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return
|
||||
setError(err instanceof Error ? err.message : 'Fehler beim Laden')
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setLoading(false)
|
||||
}
|
||||
}, [buildParams, sortBy, currentPage])
|
||||
|
||||
const loadReviewCount = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}?endpoint=controls-count&release_state=needs_review`)
|
||||
if (res.ok) { const data = await res.json(); setReviewCount(data.total || 0) }
|
||||
} catch { /* ignore */ }
|
||||
}, [])
|
||||
|
||||
useEffect(() => { loadFrameworks(); loadReviewCount() }, [loadFrameworks, loadReviewCount])
|
||||
useEffect(() => { loadMeta() }, [loadMeta])
|
||||
useEffect(() => { loadControls() }, [loadControls])
|
||||
useEffect(() => { setCurrentPage(1) }, [severityFilter, domainFilter, stateFilter, verificationFilter, categoryFilter, evidenceTypeFilter, audienceFilter, sourceFilter, typeFilter, hideDuplicates, debouncedSearch, sortBy])
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE))
|
||||
|
||||
const fullReload = useCallback(async () => {
|
||||
await Promise.all([loadControls(), loadMeta(), loadFrameworks(), loadReviewCount()])
|
||||
}, [loadControls, loadMeta, loadFrameworks, loadReviewCount])
|
||||
|
||||
const loadProcessedStats = async () => {
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}?endpoint=processed-stats`)
|
||||
if (res.ok) { const data = await res.json(); setProcessedStats(data.stats || []) }
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const enterReviewMode = async () => {
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}?endpoint=controls&release_state=needs_review&limit=1000`)
|
||||
if (res.ok) {
|
||||
const items: CanonicalControl[] = await res.json()
|
||||
if (items.length > 0) {
|
||||
const dupes = items.filter(c => c.generation_metadata?.similar_controls && Array.isArray(c.generation_metadata.similar_controls) && (c.generation_metadata.similar_controls as unknown[]).length > 0)
|
||||
const rule3 = items.filter(c => !c.generation_metadata?.similar_controls || !Array.isArray(c.generation_metadata.similar_controls) || (c.generation_metadata.similar_controls as unknown[]).length === 0)
|
||||
setReviewDuplicates(dupes)
|
||||
setReviewRule3(rule3)
|
||||
const startTab = dupes.length > 0 ? 'duplicates' : 'rule3'
|
||||
const startItems = startTab === 'duplicates' ? dupes : rule3
|
||||
setReviewTab(startTab)
|
||||
setReviewItems(startItems)
|
||||
setReviewMode(true)
|
||||
setReviewIndex(0)
|
||||
setSelectedControl(startItems[0])
|
||||
setMode('detail')
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const switchReviewTab = (tab: 'duplicates' | 'rule3') => {
|
||||
const items = tab === 'duplicates' ? reviewDuplicates : reviewRule3
|
||||
setReviewTab(tab)
|
||||
setReviewItems(items)
|
||||
setReviewIndex(0)
|
||||
if (items.length > 0) setSelectedControl(items[0])
|
||||
}
|
||||
|
||||
return {
|
||||
// Data
|
||||
frameworks, controls, totalCount, meta, selectedControl, setSelectedControl,
|
||||
loading, error,
|
||||
// Filters
|
||||
searchQuery, setSearchQuery, debouncedSearch,
|
||||
severityFilter, setSeverityFilter,
|
||||
domainFilter, setDomainFilter,
|
||||
stateFilter, setStateFilter,
|
||||
verificationFilter, setVerificationFilter,
|
||||
categoryFilter, setCategoryFilter,
|
||||
evidenceTypeFilter, setEvidenceTypeFilter,
|
||||
audienceFilter, setAudienceFilter,
|
||||
sourceFilter, setSourceFilter,
|
||||
typeFilter, setTypeFilter,
|
||||
hideDuplicates, setHideDuplicates,
|
||||
sortBy, setSortBy,
|
||||
// CRUD/UI
|
||||
mode, setMode, saving, setSaving,
|
||||
showGenerator, setShowGenerator,
|
||||
processedStats, showStats, setShowStats,
|
||||
currentPage, setCurrentPage, totalPages,
|
||||
bulkProcessing, setBulkProcessing,
|
||||
// Review
|
||||
reviewMode, setReviewMode,
|
||||
reviewIndex, setReviewIndex,
|
||||
reviewItems, setReviewItems,
|
||||
reviewCount, reviewTab,
|
||||
reviewDuplicates, reviewRule3,
|
||||
// V1 Compare
|
||||
compareMode, setCompareMode,
|
||||
compareV1Control, setCompareV1Control,
|
||||
compareMatches, setCompareMatches,
|
||||
// Actions
|
||||
fullReload, loadControls, loadMeta, loadFrameworks, loadReviewCount,
|
||||
loadProcessedStats, enterReviewMode, switchReviewTab,
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,371 +3,19 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
||||
import { mapControlTypeToDisplay, mapStatusToDisplay, DisplayControl } from './_types'
|
||||
import { ControlCard } from './_components/ControlCard'
|
||||
import { AddControlForm } from './_components/AddControlForm'
|
||||
import { LoadingSkeleton } from './_components/LoadingSkeleton'
|
||||
import { StatsCards } from './_components/StatsCards'
|
||||
import { FilterBar } from './_components/FilterBar'
|
||||
import { RAGPanel } from './_components/RAGPanel'
|
||||
import { useControlsData } from './_hooks/useControlsData'
|
||||
import { useRAGSuggestions } from './_hooks/useRAGSuggestions'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
type DisplayControlType = 'preventive' | 'detective' | 'corrective'
|
||||
type DisplayCategory = 'technical' | 'organizational' | 'physical'
|
||||
type DisplayStatus = 'implemented' | 'partial' | 'planned' | 'not-implemented'
|
||||
|
||||
interface DisplayControl {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
type: ControlType
|
||||
category: string
|
||||
implementationStatus: ImplementationStatus
|
||||
evidence: string[]
|
||||
owner: string | null
|
||||
dueDate: Date | null
|
||||
code: string
|
||||
displayType: DisplayControlType
|
||||
displayCategory: DisplayCategory
|
||||
displayStatus: DisplayStatus
|
||||
effectivenessPercent: number
|
||||
linkedRequirements: string[]
|
||||
linkedEvidence: { id: string; title: string; status: string }[]
|
||||
lastReview: Date
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// =============================================================================
|
||||
|
||||
function mapControlTypeToDisplay(type: ControlType): DisplayCategory {
|
||||
switch (type) {
|
||||
case 'TECHNICAL': return 'technical'
|
||||
case 'ORGANIZATIONAL': return 'organizational'
|
||||
case 'PHYSICAL': return 'physical'
|
||||
default: return 'technical'
|
||||
}
|
||||
}
|
||||
|
||||
function mapStatusToDisplay(status: ImplementationStatus): DisplayStatus {
|
||||
switch (status) {
|
||||
case 'IMPLEMENTED': return 'implemented'
|
||||
case 'PARTIAL': return 'partial'
|
||||
case 'NOT_IMPLEMENTED': return 'not-implemented'
|
||||
default: return 'not-implemented'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// =============================================================================
|
||||
// COMPONENTS
|
||||
// =============================================================================
|
||||
|
||||
function ControlCard({
|
||||
control,
|
||||
onStatusChange,
|
||||
onEffectivenessChange,
|
||||
onLinkEvidence,
|
||||
}: {
|
||||
control: DisplayControl
|
||||
onStatusChange: (status: ImplementationStatus) => void
|
||||
onEffectivenessChange: (effectivenessPercent: number) => void
|
||||
onLinkEvidence: () => void
|
||||
}) {
|
||||
const [showEffectivenessSlider, setShowEffectivenessSlider] = useState(false)
|
||||
|
||||
const typeColors = {
|
||||
preventive: 'bg-blue-100 text-blue-700',
|
||||
detective: 'bg-purple-100 text-purple-700',
|
||||
corrective: 'bg-orange-100 text-orange-700',
|
||||
}
|
||||
|
||||
const categoryColors = {
|
||||
technical: 'bg-green-100 text-green-700',
|
||||
organizational: 'bg-yellow-100 text-yellow-700',
|
||||
physical: 'bg-gray-100 text-gray-700',
|
||||
}
|
||||
|
||||
const statusColors = {
|
||||
implemented: 'border-green-200 bg-green-50',
|
||||
partial: 'border-yellow-200 bg-yellow-50',
|
||||
planned: 'border-blue-200 bg-blue-50',
|
||||
'not-implemented': 'border-red-200 bg-red-50',
|
||||
}
|
||||
|
||||
const statusLabels = {
|
||||
implemented: 'Implementiert',
|
||||
partial: 'Teilweise',
|
||||
planned: 'Geplant',
|
||||
'not-implemented': 'Nicht implementiert',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-xl border-2 p-6 ${statusColors[control.displayStatus]}`}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="px-2 py-1 text-xs bg-gray-100 text-gray-700 rounded font-mono">
|
||||
{control.code}
|
||||
</span>
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${typeColors[control.displayType]}`}>
|
||||
{control.displayType === 'preventive' ? 'Praeventiv' :
|
||||
control.displayType === 'detective' ? 'Detektiv' : 'Korrektiv'}
|
||||
</span>
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${categoryColors[control.displayCategory]}`}>
|
||||
{control.displayCategory === 'technical' ? 'Technisch' :
|
||||
control.displayCategory === 'organizational' ? 'Organisatorisch' : 'Physisch'}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">{control.name}</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">{control.description}</p>
|
||||
</div>
|
||||
<select
|
||||
value={control.implementationStatus}
|
||||
onChange={(e) => onStatusChange(e.target.value as ImplementationStatus)}
|
||||
className={`px-3 py-1 text-sm rounded-full border ${statusColors[control.displayStatus]}`}
|
||||
>
|
||||
<option value="NOT_IMPLEMENTED">Nicht implementiert</option>
|
||||
<option value="PARTIAL">Teilweise</option>
|
||||
<option value="IMPLEMENTED">Implementiert</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<div
|
||||
className="flex items-center justify-between text-sm mb-1 cursor-pointer"
|
||||
onClick={() => setShowEffectivenessSlider(!showEffectivenessSlider)}
|
||||
>
|
||||
<span className="text-gray-500">Wirksamkeit</span>
|
||||
<span className="font-medium">{control.effectivenessPercent}%</span>
|
||||
</div>
|
||||
<div className="h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${
|
||||
control.effectivenessPercent >= 80 ? 'bg-green-500' :
|
||||
control.effectivenessPercent >= 50 ? 'bg-yellow-500' : 'bg-red-500'
|
||||
}`}
|
||||
style={{ width: `${control.effectivenessPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
{showEffectivenessSlider && (
|
||||
<div className="mt-2">
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={control.effectivenessPercent}
|
||||
onChange={(e) => onEffectivenessChange(Number(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between text-sm">
|
||||
<div className="text-gray-500">
|
||||
<span>Verantwortlich: </span>
|
||||
<span className="font-medium text-gray-700">{control.owner || 'Nicht zugewiesen'}</span>
|
||||
</div>
|
||||
<div className="text-gray-500">
|
||||
Letzte Pruefung: {control.lastReview.toLocaleDateString('de-DE')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{control.linkedRequirements.slice(0, 3).map(req => (
|
||||
<span key={req} className="px-2 py-0.5 text-xs bg-gray-100 text-gray-600 rounded">
|
||||
{req}
|
||||
</span>
|
||||
))}
|
||||
{control.linkedRequirements.length > 3 && (
|
||||
<span className="px-2 py-0.5 text-xs bg-gray-100 text-gray-600 rounded">
|
||||
+{control.linkedRequirements.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className={`px-3 py-1 text-xs rounded-full ${
|
||||
control.displayStatus === 'implemented' ? 'bg-green-100 text-green-700' :
|
||||
control.displayStatus === 'partial' ? 'bg-yellow-100 text-yellow-700' :
|
||||
control.displayStatus === 'planned' ? 'bg-blue-100 text-blue-700' : 'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
{statusLabels[control.displayStatus]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Linked Evidence */}
|
||||
{control.linkedEvidence.length > 0 && (
|
||||
<div className="mt-3 pt-3 border-t border-gray-100">
|
||||
<span className="text-xs text-gray-500 mb-1 block">
|
||||
Nachweise: {control.linkedEvidence.length}
|
||||
{(() => {
|
||||
const e2plus = control.linkedEvidence.filter((ev: { confidenceLevel?: string }) =>
|
||||
ev.confidenceLevel && ['E2', 'E3', 'E4'].includes(ev.confidenceLevel)
|
||||
).length
|
||||
return e2plus > 0 ? ` (${e2plus} E2+)` : ''
|
||||
})()}
|
||||
</span>
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{control.linkedEvidence.map(ev => (
|
||||
<span key={ev.id} className={`px-2 py-0.5 text-xs rounded ${
|
||||
ev.status === 'valid' ? 'bg-green-50 text-green-700' :
|
||||
ev.status === 'expired' ? 'bg-red-50 text-red-700' :
|
||||
'bg-yellow-50 text-yellow-700'
|
||||
}`}>
|
||||
{ev.title}
|
||||
{(ev as { confidenceLevel?: string }).confidenceLevel && (
|
||||
<span className="ml-1 opacity-70">({(ev as { confidenceLevel?: string }).confidenceLevel})</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3 pt-3 border-t border-gray-100">
|
||||
<button
|
||||
onClick={onLinkEvidence}
|
||||
className="text-sm text-purple-600 hover:text-purple-700 font-medium"
|
||||
>
|
||||
Evidence verknuepfen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AddControlForm({
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: {
|
||||
onSubmit: (data: { name: string; description: string; type: ControlType; category: string; owner: string }) => void
|
||||
onCancel: () => void
|
||||
}) {
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
type: 'TECHNICAL' as ControlType,
|
||||
category: '',
|
||||
owner: '',
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Neue Kontrolle</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={e => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="z.B. Zugriffskontrolle"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={e => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="Beschreiben Sie die Kontrolle..."
|
||||
rows={2}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Typ</label>
|
||||
<select
|
||||
value={formData.type}
|
||||
onChange={e => setFormData({ ...formData, type: e.target.value as ControlType })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
>
|
||||
<option value="TECHNICAL">Technisch</option>
|
||||
<option value="ORGANIZATIONAL">Organisatorisch</option>
|
||||
<option value="PHYSICAL">Physisch</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Kategorie</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.category}
|
||||
onChange={e => setFormData({ ...formData, category: e.target.value })}
|
||||
placeholder="z.B. Zutrittskontrolle"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Verantwortlich</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.owner}
|
||||
onChange={e => setFormData({ ...formData, owner: e.target.value })}
|
||||
placeholder="z.B. IT Security"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 flex items-center justify-end gap-3">
|
||||
<button onClick={onCancel} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onSubmit(formData)}
|
||||
disabled={!formData.name}
|
||||
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
|
||||
formData.name ? 'bg-purple-600 text-white hover:bg-purple-700' : 'bg-gray-200 text-gray-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
Hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LoadingSkeleton() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3].map(i => (
|
||||
<div key={i} className="bg-white rounded-xl border border-gray-200 p-6 animate-pulse">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="h-5 w-20 bg-gray-200 rounded" />
|
||||
<div className="h-5 w-16 bg-gray-200 rounded-full" />
|
||||
<div className="h-5 w-16 bg-gray-200 rounded-full" />
|
||||
</div>
|
||||
<div className="h-6 w-3/4 bg-gray-200 rounded mb-2" />
|
||||
<div className="h-4 w-full bg-gray-100 rounded" />
|
||||
<div className="mt-4 h-2 bg-gray-200 rounded-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN PAGE
|
||||
// =============================================================================
|
||||
|
||||
// =============================================================================
|
||||
// RAG SUGGESTION TYPES
|
||||
// =============================================================================
|
||||
|
||||
interface RAGControlSuggestion {
|
||||
control_id: string
|
||||
domain: string
|
||||
title: string
|
||||
description: string
|
||||
pass_criteria: string
|
||||
implementation_guidance?: string
|
||||
is_automated: boolean
|
||||
automation_tool?: string
|
||||
priority: number
|
||||
confidence_score: number
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN PAGE
|
||||
// =============================================================================
|
||||
// ---------------------------------------------------------------------------
|
||||
// Transition Error Banner
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function TransitionErrorBanner({
|
||||
controlId,
|
||||
@@ -386,9 +34,7 @@ function TransitionErrorBanner({
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<div>
|
||||
<h4 className="font-medium text-orange-800">
|
||||
Status-Transition blockiert ({controlId})
|
||||
</h4>
|
||||
<h4 className="font-medium text-orange-800">Status-Transition blockiert ({controlId})</h4>
|
||||
<ul className="mt-2 space-y-1">
|
||||
{violations.map((v, i) => (
|
||||
<li key={i} className="text-sm text-orange-700 flex items-start gap-2">
|
||||
@@ -412,102 +58,66 @@ function TransitionErrorBanner({
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function ControlsPage() {
|
||||
const router = useRouter()
|
||||
const [filter, setFilter] = useState<string>('all')
|
||||
const [showAddForm, setShowAddForm] = useState(false)
|
||||
const [transitionError, setTransitionError] = useState<{ controlId: string; violations: string[] } | null>(null)
|
||||
|
||||
const {
|
||||
state,
|
||||
loading,
|
||||
error,
|
||||
setError,
|
||||
effectivenessMap,
|
||||
evidenceMap,
|
||||
handleStatusChange,
|
||||
state, dispatch, loading, error, setError,
|
||||
effectivenessMap, evidenceMap,
|
||||
handleStatusChange: _handleStatusChange,
|
||||
handleEffectivenessChange,
|
||||
handleAddControl,
|
||||
} = useControlsData()
|
||||
|
||||
// Transition error from Anti-Fake-Evidence state machine (409 Conflict)
|
||||
const [transitionError, setTransitionError] = useState<{ controlId: string; violations: string[] } | null>(null)
|
||||
const {
|
||||
ragLoading, ragSuggestions, showRagPanel, setShowRagPanel,
|
||||
selectedRequirementId, setSelectedRequirementId,
|
||||
suggestControlsFromRAG, addSuggestedControl,
|
||||
} = useRAGSuggestions(setError)
|
||||
|
||||
// Track effectiveness locally as it's not in the SDK state type
|
||||
const [effectivenessMap, setEffectivenessMap] = useState<Record<string, number>>({})
|
||||
// Track linked evidence per control
|
||||
const [evidenceMap, setEvidenceMap] = useState<Record<string, { id: string; title: string; status: string }[]>>({})
|
||||
// Wrap status change to capture 409 transition errors
|
||||
const handleStatusChange = async (controlId: string, newStatus: import('@/lib/sdk').ImplementationStatus) => {
|
||||
const oldControl = state.controls.find(c => c.id === controlId)
|
||||
const oldStatus = oldControl?.implementationStatus
|
||||
|
||||
dispatch({ type: 'UPDATE_CONTROL', payload: { id: controlId, data: { implementationStatus: newStatus } } })
|
||||
|
||||
const fetchEvidenceForControls = async (controlIds: string[]) => {
|
||||
try {
|
||||
const res = await fetch('/api/sdk/v1/compliance/evidence')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
const allEvidence = data.evidence || data
|
||||
if (Array.isArray(allEvidence)) {
|
||||
const map: Record<string, { id: string; title: string; status: string; confidenceLevel?: string }[]> = {}
|
||||
for (const ev of allEvidence) {
|
||||
const ctrlId = ev.control_id || ''
|
||||
if (!map[ctrlId]) map[ctrlId] = []
|
||||
map[ctrlId].push({
|
||||
id: ev.id,
|
||||
title: ev.title || ev.name || 'Nachweis',
|
||||
status: ev.status || 'pending',
|
||||
confidenceLevel: ev.confidence_level || undefined,
|
||||
const res = await fetch(`/api/sdk/v1/compliance/controls/${controlId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ implementation_status: newStatus }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
if (oldStatus) dispatch({ type: 'UPDATE_CONTROL', payload: { id: controlId, data: { implementationStatus: oldStatus } } })
|
||||
const err = await res.json().catch(() => ({ detail: 'Status-Aenderung fehlgeschlagen' }))
|
||||
if (res.status === 409 && err.detail?.violations) {
|
||||
setTransitionError({ controlId, violations: err.detail.violations })
|
||||
} else {
|
||||
const msg = typeof err.detail === 'string' ? err.detail : err.detail?.error || 'Status-Aenderung fehlgeschlagen'
|
||||
setError(msg)
|
||||
}
|
||||
setEvidenceMap(map)
|
||||
}
|
||||
} else if (transitionError?.controlId === controlId) {
|
||||
setTransitionError(null)
|
||||
}
|
||||
} catch {
|
||||
// Silently fail
|
||||
if (oldStatus) dispatch({ type: 'UPDATE_CONTROL', payload: { id: controlId, data: { implementationStatus: oldStatus } } })
|
||||
setError('Netzwerkfehler bei Status-Aenderung')
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch controls from backend on mount
|
||||
useEffect(() => {
|
||||
const fetchControls = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const res = await fetch('/api/sdk/v1/compliance/controls')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
const backendControls = data.controls || data
|
||||
if (Array.isArray(backendControls) && backendControls.length > 0) {
|
||||
const mapped: SDKControl[] = backendControls.map((c: Record<string, unknown>) => ({
|
||||
id: (c.control_id || c.id) as string,
|
||||
name: (c.name || c.title || '') as string,
|
||||
description: (c.description || '') as string,
|
||||
type: ((c.type || c.control_type || 'TECHNICAL') as string).toUpperCase() as ControlType,
|
||||
category: (c.category || '') as string,
|
||||
implementationStatus: ((c.implementation_status || c.status || 'NOT_IMPLEMENTED') as string).toUpperCase() as ImplementationStatus,
|
||||
effectiveness: (c.effectiveness || 'LOW') as 'LOW' | 'MEDIUM' | 'HIGH',
|
||||
evidence: (c.evidence || []) as string[],
|
||||
owner: (c.owner || null) as string | null,
|
||||
dueDate: c.due_date ? new Date(c.due_date as string) : null,
|
||||
}))
|
||||
dispatch({ type: 'SET_STATE', payload: { controls: mapped } })
|
||||
setError(null)
|
||||
// Fetch evidence for all controls
|
||||
fetchEvidenceForControls(mapped.map(c => c.id))
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// API not available — show empty state
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchControls()
|
||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Convert SDK controls to display controls
|
||||
// Build display controls
|
||||
const displayControls: DisplayControl[] = state.controls.map(ctrl => {
|
||||
const effectivenessPercent = effectivenessMap[ctrl.id] ??
|
||||
(ctrl.implementationStatus === 'IMPLEMENTED' ? 85 :
|
||||
ctrl.implementationStatus === 'PARTIAL' ? 50 : 0)
|
||||
|
||||
(ctrl.implementationStatus === 'IMPLEMENTED' ? 85 : ctrl.implementationStatus === 'PARTIAL' ? 50 : 0)
|
||||
return {
|
||||
id: ctrl.id,
|
||||
name: ctrl.name,
|
||||
@@ -519,7 +129,7 @@ export default function ControlsPage() {
|
||||
owner: ctrl.owner,
|
||||
dueDate: ctrl.dueDate,
|
||||
code: ctrl.id,
|
||||
displayType: 'preventive' as DisplayControlType,
|
||||
displayType: 'preventive' as import('./_types').DisplayControlType,
|
||||
displayCategory: mapControlTypeToDisplay(ctrl.type),
|
||||
displayStatus: mapStatusToDisplay(ctrl.implementationStatus),
|
||||
effectivenessPercent,
|
||||
@@ -531,160 +141,18 @@ export default function ControlsPage() {
|
||||
|
||||
const filteredControls = filter === 'all'
|
||||
? displayControls
|
||||
: displayControls.filter(c =>
|
||||
c.displayStatus === filter ||
|
||||
c.displayType === filter ||
|
||||
c.displayCategory === filter
|
||||
)
|
||||
: displayControls.filter(c => c.displayStatus === filter || c.displayType === filter || c.displayCategory === filter)
|
||||
|
||||
const implementedCount = displayControls.filter(c => c.displayStatus === 'implemented').length
|
||||
const avgEffectiveness = displayControls.length > 0
|
||||
? Math.round(displayControls.reduce((sum, c) => sum + c.effectivenessPercent, 0) / displayControls.length)
|
||||
: 0
|
||||
const partialCount = displayControls.filter(c => c.displayStatus === 'partial').length
|
||||
|
||||
const handleStatusChange = async (controlId: string, newStatus: ImplementationStatus) => {
|
||||
// Remember old status for rollback
|
||||
const oldControl = state.controls.find(c => c.id === controlId)
|
||||
const oldStatus = oldControl?.implementationStatus
|
||||
|
||||
// Optimistic update
|
||||
dispatch({
|
||||
type: 'UPDATE_CONTROL',
|
||||
payload: { id: controlId, data: { implementationStatus: newStatus } },
|
||||
})
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/compliance/controls/${controlId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ implementation_status: newStatus }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
// Rollback optimistic update
|
||||
if (oldStatus) {
|
||||
dispatch({
|
||||
type: 'UPDATE_CONTROL',
|
||||
payload: { id: controlId, data: { implementationStatus: oldStatus } },
|
||||
})
|
||||
}
|
||||
|
||||
const err = await res.json().catch(() => ({ detail: 'Status-Aenderung fehlgeschlagen' }))
|
||||
|
||||
if (res.status === 409 && err.detail?.violations) {
|
||||
setTransitionError({ controlId, violations: err.detail.violations })
|
||||
} else {
|
||||
const msg = typeof err.detail === 'string' ? err.detail : err.detail?.error || 'Status-Aenderung fehlgeschlagen'
|
||||
setError(msg)
|
||||
}
|
||||
} else {
|
||||
// Clear any previous transition error for this control
|
||||
if (transitionError?.controlId === controlId) {
|
||||
setTransitionError(null)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Network error — rollback
|
||||
if (oldStatus) {
|
||||
dispatch({
|
||||
type: 'UPDATE_CONTROL',
|
||||
payload: { id: controlId, data: { implementationStatus: oldStatus } },
|
||||
})
|
||||
}
|
||||
setError('Netzwerkfehler bei Status-Aenderung')
|
||||
}
|
||||
}
|
||||
|
||||
const handleEffectivenessChange = async (controlId: string, effectiveness: number) => {
|
||||
setEffectivenessMap(prev => ({ ...prev, [controlId]: effectiveness }))
|
||||
|
||||
// Persist to backend
|
||||
try {
|
||||
await fetch(`/api/sdk/v1/compliance/controls/${controlId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ effectiveness_score: effectiveness }),
|
||||
})
|
||||
} catch {
|
||||
// Silently fail — local state is already updated
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddControl = (data: { name: string; description: string; type: ControlType; category: string; owner: string }) => {
|
||||
const newControl: SDKControl = {
|
||||
id: `ctrl-${Date.now()}`,
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
type: data.type,
|
||||
category: data.category,
|
||||
implementationStatus: 'NOT_IMPLEMENTED',
|
||||
effectiveness: 'LOW',
|
||||
evidence: [],
|
||||
owner: data.owner || null,
|
||||
dueDate: null,
|
||||
}
|
||||
dispatch({ type: 'ADD_CONTROL', payload: newControl })
|
||||
setShowAddForm(false)
|
||||
}
|
||||
|
||||
const suggestControlsFromRAG = async () => {
|
||||
if (!selectedRequirementId) {
|
||||
setError('Bitte eine Anforderungs-ID eingeben.')
|
||||
return
|
||||
}
|
||||
setRagLoading(true)
|
||||
setRagSuggestions([])
|
||||
try {
|
||||
const res = await fetch('/api/sdk/v1/compliance/ai/suggest-controls', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ requirement_id: selectedRequirementId }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const msg = await res.text()
|
||||
throw new Error(msg || `HTTP ${res.status}`)
|
||||
}
|
||||
const data = await res.json()
|
||||
setRagSuggestions(data.suggestions || [])
|
||||
setShowRagPanel(true)
|
||||
} catch (e) {
|
||||
setError(`KI-Vorschläge fehlgeschlagen: ${e instanceof Error ? e.message : 'Unbekannter Fehler'}`)
|
||||
} finally {
|
||||
setRagLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const addSuggestedControl = (suggestion: RAGControlSuggestion) => {
|
||||
const newControl: import('@/lib/sdk').Control = {
|
||||
id: `rag-${suggestion.control_id}-${Date.now()}`,
|
||||
name: suggestion.title,
|
||||
description: suggestion.description,
|
||||
type: 'TECHNICAL',
|
||||
category: suggestion.domain,
|
||||
implementationStatus: 'NOT_IMPLEMENTED',
|
||||
effectiveness: 'LOW',
|
||||
evidence: [],
|
||||
owner: null,
|
||||
dueDate: null,
|
||||
}
|
||||
dispatch({ type: 'ADD_CONTROL', payload: newControl })
|
||||
// Remove from suggestions after adding
|
||||
setRagSuggestions(prev => prev.filter(s => s.control_id !== suggestion.control_id))
|
||||
}
|
||||
|
||||
const stepInfo = STEP_EXPLANATIONS['controls']
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Step Header */}
|
||||
<StepHeader
|
||||
stepId="controls"
|
||||
title={stepInfo.title}
|
||||
description={stepInfo.description}
|
||||
explanation={stepInfo.explanation}
|
||||
tips={stepInfo.tips}
|
||||
>
|
||||
<StepHeader stepId="controls" title={stepInfo.title} description={stepInfo.description} explanation={stepInfo.explanation} tips={stepInfo.tips}>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setShowRagPanel(!showRagPanel)}
|
||||
@@ -707,7 +175,6 @@ export default function ControlsPage() {
|
||||
</div>
|
||||
</StepHeader>
|
||||
|
||||
{/* Add Form */}
|
||||
{showAddForm && (
|
||||
<AddControlForm
|
||||
onSubmit={(data) => { handleAddControl(data); setShowAddForm(false) }}
|
||||
@@ -715,7 +182,6 @@ export default function ControlsPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* RAG Controls Panel */}
|
||||
{showRagPanel && (
|
||||
<RAGPanel
|
||||
selectedRequirementId={selectedRequirementId}
|
||||
@@ -729,7 +195,6 @@ export default function ControlsPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Error Banner */}
|
||||
{error && (
|
||||
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center justify-between">
|
||||
<span>{error}</span>
|
||||
@@ -737,7 +202,6 @@ export default function ControlsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Transition Error Banner (Anti-Fake-Evidence 409 violations) */}
|
||||
{transitionError && (
|
||||
<TransitionErrorBanner
|
||||
controlId={transitionError.controlId}
|
||||
@@ -746,7 +210,6 @@ export default function ControlsPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Requirements Alert */}
|
||||
{state.requirements.length === 0 && !loading && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
@@ -763,19 +226,12 @@ export default function ControlsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<StatsCards
|
||||
total={displayControls.length}
|
||||
implementedCount={implementedCount}
|
||||
avgEffectiveness={avgEffectiveness}
|
||||
partialCount={partialCount}
|
||||
/>
|
||||
<StatsCards total={displayControls.length} implementedCount={implementedCount} avgEffectiveness={avgEffectiveness} partialCount={partialCount} />
|
||||
|
||||
<FilterBar filter={filter} onFilterChange={setFilter} />
|
||||
|
||||
{/* Loading State */}
|
||||
{loading && <LoadingSkeleton />}
|
||||
|
||||
{/* Controls List */}
|
||||
{!loading && (
|
||||
<div className="space-y-4">
|
||||
{filteredControls.map(control => (
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Component, ComponentFormData, COMPONENT_TYPES } from './types'
|
||||
|
||||
export function ComponentForm({
|
||||
onSubmit,
|
||||
onCancel,
|
||||
initialData,
|
||||
parentId,
|
||||
}: {
|
||||
onSubmit: (data: ComponentFormData) => void
|
||||
onCancel: () => void
|
||||
initialData?: Component | null
|
||||
parentId?: string | null
|
||||
}) {
|
||||
const [formData, setFormData] = useState<ComponentFormData>({
|
||||
name: initialData?.name || '',
|
||||
type: initialData?.type || 'SW',
|
||||
version: initialData?.version || '',
|
||||
description: initialData?.description || '',
|
||||
safety_relevant: initialData?.safety_relevant || false,
|
||||
parent_id: parentId || initialData?.parent_id || null,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
{initialData ? 'Komponente bearbeiten' : parentId ? 'Unterkomponente hinzufuegen' : 'Neue Komponente'}
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="z.B. Bildverarbeitungsmodul"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Typ</label>
|
||||
<select
|
||||
value={formData.type}
|
||||
onChange={(e) => setFormData({ ...formData, type: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
>
|
||||
{COMPONENT_TYPES.map((t) => (
|
||||
<option key={t.value} value={t.value}>{t.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Version</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.version}
|
||||
onChange={(e) => setFormData({ ...formData, version: e.target.value })}
|
||||
placeholder="z.B. 1.2.0"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 pt-6">
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.safety_relevant}
|
||||
onChange={(e) => setFormData({ ...formData, safety_relevant: e.target.checked })}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-9 h-5 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-purple-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-red-500" />
|
||||
</label>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Sicherheitsrelevant</span>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Beschreibung</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="Kurze Beschreibung der Komponente..."
|
||||
rows={2}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => onSubmit(formData)}
|
||||
disabled={!formData.name}
|
||||
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
|
||||
formData.name
|
||||
? 'bg-purple-600 text-white hover:bg-purple-700'
|
||||
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
{initialData ? 'Aktualisieren' : 'Hinzufuegen'}
|
||||
</button>
|
||||
<button onClick={onCancel} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { LibraryComponent, EnergySource, LIBRARY_CATEGORIES } from './types'
|
||||
import { ComponentTypeIcon } from './ComponentTypeIcon'
|
||||
|
||||
export function ComponentLibraryModal({
|
||||
onAdd,
|
||||
onClose,
|
||||
}: {
|
||||
onAdd: (components: LibraryComponent[], energySources: EnergySource[]) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [libraryComponents, setLibraryComponents] = useState<LibraryComponent[]>([])
|
||||
const [energySources, setEnergySources] = useState<EnergySource[]>([])
|
||||
const [selectedComponents, setSelectedComponents] = useState<Set<string>>(new Set())
|
||||
const [selectedEnergySources, setSelectedEnergySources] = useState<Set<string>>(new Set())
|
||||
const [search, setSearch] = useState('')
|
||||
const [filterCategory, setFilterCategory] = useState('')
|
||||
const [activeTab, setActiveTab] = useState<'components' | 'energy'>('components')
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
try {
|
||||
const [compRes, enRes] = await Promise.all([
|
||||
fetch('/api/sdk/v1/iace/component-library'),
|
||||
fetch('/api/sdk/v1/iace/energy-sources'),
|
||||
])
|
||||
if (compRes.ok) {
|
||||
const json = await compRes.json()
|
||||
setLibraryComponents(json.components || [])
|
||||
}
|
||||
if (enRes.ok) {
|
||||
const json = await enRes.json()
|
||||
setEnergySources(json.energy_sources || [])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch library:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
fetchData()
|
||||
}, [])
|
||||
|
||||
function toggleComponent(id: string) {
|
||||
setSelectedComponents(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function toggleEnergySource(id: string) {
|
||||
setSelectedEnergySources(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function toggleAllInCategory(category: string) {
|
||||
const items = libraryComponents.filter(c => c.category === category)
|
||||
const allIds = items.map(i => i.id)
|
||||
const allSelected = allIds.every(id => selectedComponents.has(id))
|
||||
setSelectedComponents(prev => {
|
||||
const next = new Set(prev)
|
||||
allIds.forEach(id => allSelected ? next.delete(id) : next.add(id))
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function handleAdd() {
|
||||
const selComps = libraryComponents.filter(c => selectedComponents.has(c.id))
|
||||
const selEnergy = energySources.filter(e => selectedEnergySources.has(e.id))
|
||||
onAdd(selComps, selEnergy)
|
||||
}
|
||||
|
||||
const categories = Object.keys(LIBRARY_CATEGORIES)
|
||||
const filtered = libraryComponents.filter(c => {
|
||||
if (filterCategory && c.category !== filterCategory) return false
|
||||
if (search) {
|
||||
const q = search.toLowerCase()
|
||||
return c.name_de.toLowerCase().includes(q) || c.name_en.toLowerCase().includes(q) || c.description_de.toLowerCase().includes(q)
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
const grouped = filtered.reduce<Record<string, LibraryComponent[]>>((acc, c) => {
|
||||
if (!acc[c.category]) acc[c.category] = []
|
||||
acc[c.category].push(c)
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
const totalSelected = selectedComponents.size + selectedEnergySources.size
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl p-8 text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600 mx-auto" />
|
||||
<p className="mt-3 text-sm text-gray-500">Bibliothek wird geladen...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl w-full max-w-4xl max-h-[85vh] flex flex-col">
|
||||
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Komponentenbibliothek</h3>
|
||||
<button onClick={onClose} className="p-1 text-gray-400 hover:text-gray-600 rounded">
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex gap-2 mb-4">
|
||||
<button onClick={() => setActiveTab('components')}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${activeTab === 'components' ? 'bg-purple-100 text-purple-700' : 'text-gray-500 hover:bg-gray-100'}`}>
|
||||
Komponenten ({libraryComponents.length})
|
||||
</button>
|
||||
<button onClick={() => setActiveTab('energy')}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${activeTab === 'energy' ? 'bg-purple-100 text-purple-700' : 'text-gray-500 hover:bg-gray-100'}`}>
|
||||
Energiequellen ({energySources.length})
|
||||
</button>
|
||||
</div>
|
||||
{activeTab === 'components' && (
|
||||
<div className="flex gap-3">
|
||||
<input type="text" value={search} onChange={e => setSearch(e.target.value)} placeholder="Suchen..."
|
||||
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white" />
|
||||
<select value={filterCategory} onChange={e => setFilterCategory(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white">
|
||||
<option value="">Alle Kategorien</option>
|
||||
{categories.map(cat => <option key={cat} value={cat}>{LIBRARY_CATEGORIES[cat]}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
{activeTab === 'components' ? (
|
||||
<div className="space-y-4">
|
||||
{Object.entries(grouped)
|
||||
.sort(([a], [b]) => categories.indexOf(a) - categories.indexOf(b))
|
||||
.map(([category, items]) => (
|
||||
<div key={category}>
|
||||
<div className="flex items-center gap-2 mb-2 sticky top-0 bg-white dark:bg-gray-800 py-1 z-10">
|
||||
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300">{LIBRARY_CATEGORIES[category] || category}</h4>
|
||||
<span className="text-xs text-gray-400">({items.length})</span>
|
||||
<button onClick={() => toggleAllInCategory(category)} className="text-xs text-purple-600 hover:text-purple-700 ml-auto">
|
||||
{items.every(i => selectedComponents.has(i.id)) ? 'Alle abwaehlen' : 'Alle waehlen'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{items.map(comp => (
|
||||
<label key={comp.id}
|
||||
className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
|
||||
selectedComponents.has(comp.id) ? 'border-purple-400 bg-purple-50 dark:bg-purple-900/20' : 'border-gray-200 hover:bg-gray-50 dark:border-gray-700'
|
||||
}`}>
|
||||
<input type="checkbox" checked={selectedComponents.has(comp.id)} onChange={() => toggleComponent(comp.id)} className="mt-0.5 accent-purple-600" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-mono text-gray-400">{comp.id}</span>
|
||||
<ComponentTypeIcon type={comp.maps_to_component_type} />
|
||||
</div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">{comp.name_de}</div>
|
||||
{comp.description_de && <div className="text-xs text-gray-500 mt-0.5 line-clamp-2">{comp.description_de}</div>}
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{filtered.length === 0 && <div className="text-center py-8 text-gray-500">Keine Komponenten gefunden</div>}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{energySources.map(es => (
|
||||
<label key={es.id}
|
||||
className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
|
||||
selectedEnergySources.has(es.id) ? 'border-purple-400 bg-purple-50 dark:bg-purple-900/20' : 'border-gray-200 hover:bg-gray-50 dark:border-gray-700'
|
||||
}`}>
|
||||
<input type="checkbox" checked={selectedEnergySources.has(es.id)} onChange={() => toggleEnergySource(es.id)} className="mt-0.5 accent-purple-600" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2"><span className="text-xs font-mono text-gray-400">{es.id}</span></div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">{es.name_de}</div>
|
||||
{es.description_de && <div className="text-xs text-gray-500 mt-0.5 line-clamp-2">{es.description_de}</div>}
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<span className="text-sm text-gray-500">
|
||||
{selectedComponents.size} Komponenten, {selectedEnergySources.size} Energiequellen ausgewaehlt
|
||||
</span>
|
||||
<div className="flex gap-3">
|
||||
<button onClick={onClose} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">Abbrechen</button>
|
||||
<button onClick={handleAdd} disabled={totalSelected === 0}
|
||||
className={`px-6 py-2 rounded-lg font-medium transition-colors ${totalSelected > 0 ? 'bg-purple-600 text-white hover:bg-purple-700' : 'bg-gray-200 text-gray-400 cursor-not-allowed'}`}>
|
||||
{totalSelected > 0 ? `${totalSelected} hinzufuegen` : 'Auswaehlen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Component } from './types'
|
||||
import { ComponentTypeIcon } from './ComponentTypeIcon'
|
||||
|
||||
export function ComponentTreeNode({
|
||||
component,
|
||||
depth,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onAddChild,
|
||||
}: {
|
||||
component: Component
|
||||
depth: number
|
||||
onEdit: (c: Component) => void
|
||||
onDelete: (id: string) => void
|
||||
onAddChild: (parentId: string) => void
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(true)
|
||||
const hasChildren = component.children && component.children.length > 0
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className="flex items-center gap-2 py-2 px-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 group transition-colors"
|
||||
style={{ paddingLeft: `${depth * 24 + 12}px` }}
|
||||
>
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className={`w-5 h-5 flex items-center justify-center text-gray-400 ${hasChildren ? 'visible' : 'invisible'}`}
|
||||
>
|
||||
<svg
|
||||
className={`w-4 h-4 transition-transform ${expanded ? '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>
|
||||
</button>
|
||||
|
||||
<ComponentTypeIcon type={component.type} />
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">{component.name}</span>
|
||||
{component.version && <span className="ml-2 text-xs text-gray-400">v{component.version}</span>}
|
||||
{component.safety_relevant && (
|
||||
<span className="ml-2 inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-red-100 text-red-700">
|
||||
Sicherheitsrelevant
|
||||
</span>
|
||||
)}
|
||||
{component.library_component_id && (
|
||||
<span className="ml-2 inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-700">
|
||||
Bibliothek
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{component.description && (
|
||||
<span className="text-xs text-gray-400 truncate max-w-[200px] hidden lg:block">
|
||||
{component.description}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button onClick={() => onAddChild(component.id)} title="Unterkomponente hinzufuegen"
|
||||
className="p-1 text-gray-400 hover:text-purple-600 hover:bg-purple-50 rounded transition-colors">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
</button>
|
||||
<button onClick={() => onEdit(component)} title="Bearbeiten"
|
||||
className="p-1 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded transition-colors">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button onClick={() => onDelete(component.id)} title="Loeschen"
|
||||
className="p-1 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded transition-colors">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expanded && hasChildren && (
|
||||
<div>
|
||||
{component.children.map((child) => (
|
||||
<ComponentTreeNode
|
||||
key={child.id}
|
||||
component={child}
|
||||
depth={depth + 1}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onAddChild={onAddChild}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
'use client'
|
||||
|
||||
export function ComponentTypeIcon({ type }: { type: string }) {
|
||||
const colors: Record<string, string> = {
|
||||
SW: 'bg-blue-100 text-blue-700',
|
||||
FW: 'bg-indigo-100 text-indigo-700',
|
||||
AI: 'bg-purple-100 text-purple-700',
|
||||
HMI: 'bg-pink-100 text-pink-700',
|
||||
SENSOR: 'bg-cyan-100 text-cyan-700',
|
||||
ACTUATOR: 'bg-orange-100 text-orange-700',
|
||||
CONTROLLER: 'bg-green-100 text-green-700',
|
||||
NETWORK: 'bg-yellow-100 text-yellow-700',
|
||||
MECHANICAL: 'bg-gray-100 text-gray-700',
|
||||
ELECTRICAL: 'bg-red-100 text-red-700',
|
||||
OTHER: 'bg-gray-100 text-gray-500',
|
||||
}
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${colors[type] || colors.OTHER}`}>
|
||||
{type}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
export interface Component {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
version: string
|
||||
description: string
|
||||
safety_relevant: boolean
|
||||
parent_id: string | null
|
||||
children: Component[]
|
||||
library_component_id?: string
|
||||
energy_source_ids?: string[]
|
||||
}
|
||||
|
||||
export interface LibraryComponent {
|
||||
id: string
|
||||
name_de: string
|
||||
name_en: string
|
||||
category: string
|
||||
description_de: string
|
||||
typical_hazard_categories: string[]
|
||||
typical_energy_sources: string[]
|
||||
maps_to_component_type: string
|
||||
tags: string[]
|
||||
sort_order: number
|
||||
}
|
||||
|
||||
export interface EnergySource {
|
||||
id: string
|
||||
name_de: string
|
||||
name_en: string
|
||||
description_de: string
|
||||
typical_components: string[]
|
||||
typical_hazard_categories: string[]
|
||||
tags: string[]
|
||||
sort_order: number
|
||||
}
|
||||
|
||||
export interface ComponentFormData {
|
||||
name: string
|
||||
type: string
|
||||
version: string
|
||||
description: string
|
||||
safety_relevant: boolean
|
||||
parent_id: string | null
|
||||
}
|
||||
|
||||
export const LIBRARY_CATEGORIES: Record<string, string> = {
|
||||
mechanical: 'Mechanik',
|
||||
structural: 'Struktur',
|
||||
drive: 'Antrieb',
|
||||
hydraulic: 'Hydraulik',
|
||||
pneumatic: 'Pneumatik',
|
||||
electrical: 'Elektrik',
|
||||
control: 'Steuerung',
|
||||
sensor: 'Sensorik',
|
||||
actuator: 'Aktorik',
|
||||
safety: 'Sicherheit',
|
||||
it_network: 'IT/Netzwerk',
|
||||
}
|
||||
|
||||
export const COMPONENT_TYPES = [
|
||||
{ value: 'SW', label: 'Software (SW)' },
|
||||
{ value: 'FW', label: 'Firmware (FW)' },
|
||||
{ value: 'AI', label: 'KI-Modul (AI)' },
|
||||
{ value: 'HMI', label: 'Mensch-Maschine-Schnittstelle (HMI)' },
|
||||
{ value: 'SENSOR', label: 'Sensor' },
|
||||
{ value: 'ACTUATOR', label: 'Aktor' },
|
||||
{ value: 'CONTROLLER', label: 'Steuerung' },
|
||||
{ value: 'NETWORK', label: 'Netzwerk' },
|
||||
{ value: 'MECHANICAL', label: 'Mechanik' },
|
||||
{ value: 'ELECTRICAL', label: 'Elektrik' },
|
||||
{ value: 'OTHER', label: 'Sonstiges' },
|
||||
]
|
||||
|
||||
export function buildTree(components: Component[]): Component[] {
|
||||
const map = new Map<string, Component>()
|
||||
const roots: Component[] = []
|
||||
|
||||
components.forEach((c) => {
|
||||
map.set(c.id, { ...c, children: [] })
|
||||
})
|
||||
|
||||
components.forEach((c) => {
|
||||
const node = map.get(c.id)!
|
||||
if (c.parent_id && map.has(c.parent_id)) {
|
||||
map.get(c.parent_id)!.children.push(node)
|
||||
} else {
|
||||
roots.push(node)
|
||||
}
|
||||
})
|
||||
|
||||
return roots
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Component, ComponentFormData, LibraryComponent, EnergySource, buildTree } from '../_components/types'
|
||||
|
||||
export function useComponents(projectId: string) {
|
||||
const [components, setComponents] = useState<Component[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
fetchComponents()
|
||||
}, [projectId]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
async function fetchComponents() {
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/components`)
|
||||
if (res.ok) {
|
||||
const json = await res.json()
|
||||
setComponents(json.components || json || [])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch components:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(data: ComponentFormData, editingId?: string) {
|
||||
try {
|
||||
const url = editingId
|
||||
? `/api/sdk/v1/iace/projects/${projectId}/components/${editingId}`
|
||||
: `/api/sdk/v1/iace/projects/${projectId}/components`
|
||||
const method = editingId ? 'PUT' : 'POST'
|
||||
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
if (res.ok) {
|
||||
await fetchComponents()
|
||||
return true
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to save component:', err)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
if (!confirm('Komponente wirklich loeschen? Unterkomponenten werden ebenfalls entfernt.')) return
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/components/${id}`, { method: 'DELETE' })
|
||||
if (res.ok) await fetchComponents()
|
||||
} catch (err) {
|
||||
console.error('Failed to delete component:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddFromLibrary(libraryComps: LibraryComponent[], energySrcs: EnergySource[]) {
|
||||
const energySourceIds = energySrcs.map(e => e.id)
|
||||
for (const comp of libraryComps) {
|
||||
try {
|
||||
await fetch(`/api/sdk/v1/iace/projects/${projectId}/components`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: comp.name_de,
|
||||
type: comp.maps_to_component_type,
|
||||
description: comp.description_de,
|
||||
safety_relevant: false,
|
||||
library_component_id: comp.id,
|
||||
energy_source_ids: energySourceIds,
|
||||
tags: comp.tags,
|
||||
}),
|
||||
})
|
||||
} catch (err) {
|
||||
console.error(`Failed to add component ${comp.id}:`, err)
|
||||
}
|
||||
}
|
||||
await fetchComponents()
|
||||
}
|
||||
|
||||
return {
|
||||
components,
|
||||
loading,
|
||||
tree: buildTree(components),
|
||||
handleSubmit,
|
||||
handleDelete,
|
||||
handleAddFromLibrary,
|
||||
}
|
||||
}
|
||||
@@ -1,687 +1,24 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
|
||||
interface Component {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
version: string
|
||||
description: string
|
||||
safety_relevant: boolean
|
||||
parent_id: string | null
|
||||
children: Component[]
|
||||
library_component_id?: string
|
||||
energy_source_ids?: string[]
|
||||
}
|
||||
|
||||
interface LibraryComponent {
|
||||
id: string
|
||||
name_de: string
|
||||
name_en: string
|
||||
category: string
|
||||
description_de: string
|
||||
typical_hazard_categories: string[]
|
||||
typical_energy_sources: string[]
|
||||
maps_to_component_type: string
|
||||
tags: string[]
|
||||
sort_order: number
|
||||
}
|
||||
|
||||
interface EnergySource {
|
||||
id: string
|
||||
name_de: string
|
||||
name_en: string
|
||||
description_de: string
|
||||
typical_components: string[]
|
||||
typical_hazard_categories: string[]
|
||||
tags: string[]
|
||||
sort_order: number
|
||||
}
|
||||
|
||||
const LIBRARY_CATEGORIES: Record<string, string> = {
|
||||
mechanical: 'Mechanik',
|
||||
structural: 'Struktur',
|
||||
drive: 'Antrieb',
|
||||
hydraulic: 'Hydraulik',
|
||||
pneumatic: 'Pneumatik',
|
||||
electrical: 'Elektrik',
|
||||
control: 'Steuerung',
|
||||
sensor: 'Sensorik',
|
||||
actuator: 'Aktorik',
|
||||
safety: 'Sicherheit',
|
||||
it_network: 'IT/Netzwerk',
|
||||
}
|
||||
|
||||
const COMPONENT_TYPES = [
|
||||
{ value: 'SW', label: 'Software (SW)' },
|
||||
{ value: 'FW', label: 'Firmware (FW)' },
|
||||
{ value: 'AI', label: 'KI-Modul (AI)' },
|
||||
{ value: 'HMI', label: 'Mensch-Maschine-Schnittstelle (HMI)' },
|
||||
{ value: 'SENSOR', label: 'Sensor' },
|
||||
{ value: 'ACTUATOR', label: 'Aktor' },
|
||||
{ value: 'CONTROLLER', label: 'Steuerung' },
|
||||
{ value: 'NETWORK', label: 'Netzwerk' },
|
||||
{ value: 'MECHANICAL', label: 'Mechanik' },
|
||||
{ value: 'ELECTRICAL', label: 'Elektrik' },
|
||||
{ value: 'OTHER', label: 'Sonstiges' },
|
||||
]
|
||||
|
||||
function ComponentTypeIcon({ type }: { type: string }) {
|
||||
const colors: Record<string, string> = {
|
||||
SW: 'bg-blue-100 text-blue-700',
|
||||
FW: 'bg-indigo-100 text-indigo-700',
|
||||
AI: 'bg-purple-100 text-purple-700',
|
||||
HMI: 'bg-pink-100 text-pink-700',
|
||||
SENSOR: 'bg-cyan-100 text-cyan-700',
|
||||
ACTUATOR: 'bg-orange-100 text-orange-700',
|
||||
CONTROLLER: 'bg-green-100 text-green-700',
|
||||
NETWORK: 'bg-yellow-100 text-yellow-700',
|
||||
MECHANICAL: 'bg-gray-100 text-gray-700',
|
||||
ELECTRICAL: 'bg-red-100 text-red-700',
|
||||
OTHER: 'bg-gray-100 text-gray-500',
|
||||
}
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${colors[type] || colors.OTHER}`}>
|
||||
{type}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function ComponentTreeNode({
|
||||
component,
|
||||
depth,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onAddChild,
|
||||
}: {
|
||||
component: Component
|
||||
depth: number
|
||||
onEdit: (c: Component) => void
|
||||
onDelete: (id: string) => void
|
||||
onAddChild: (parentId: string) => void
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(true)
|
||||
const hasChildren = component.children && component.children.length > 0
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className="flex items-center gap-2 py-2 px-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 group transition-colors"
|
||||
style={{ paddingLeft: `${depth * 24 + 12}px` }}
|
||||
>
|
||||
{/* Expand/collapse */}
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className={`w-5 h-5 flex items-center justify-center text-gray-400 ${hasChildren ? 'visible' : 'invisible'}`}
|
||||
>
|
||||
<svg
|
||||
className={`w-4 h-4 transition-transform ${expanded ? '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>
|
||||
</button>
|
||||
|
||||
<ComponentTypeIcon type={component.type} />
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">{component.name}</span>
|
||||
{component.version && (
|
||||
<span className="ml-2 text-xs text-gray-400">v{component.version}</span>
|
||||
)}
|
||||
{component.safety_relevant && (
|
||||
<span className="ml-2 inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-red-100 text-red-700">
|
||||
Sicherheitsrelevant
|
||||
</span>
|
||||
)}
|
||||
{component.library_component_id && (
|
||||
<span className="ml-2 inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-700">
|
||||
Bibliothek
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{component.description && (
|
||||
<span className="text-xs text-gray-400 truncate max-w-[200px] hidden lg:block">
|
||||
{component.description}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={() => onAddChild(component.id)}
|
||||
title="Unterkomponente hinzufuegen"
|
||||
className="p-1 text-gray-400 hover:text-purple-600 hover:bg-purple-50 rounded transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onEdit(component)}
|
||||
title="Bearbeiten"
|
||||
className="p-1 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDelete(component.id)}
|
||||
title="Loeschen"
|
||||
className="p-1 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expanded && hasChildren && (
|
||||
<div>
|
||||
{component.children.map((child) => (
|
||||
<ComponentTreeNode
|
||||
key={child.id}
|
||||
component={child}
|
||||
depth={depth + 1}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onAddChild={onAddChild}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ComponentFormData {
|
||||
name: string
|
||||
type: string
|
||||
version: string
|
||||
description: string
|
||||
safety_relevant: boolean
|
||||
parent_id: string | null
|
||||
}
|
||||
|
||||
function ComponentForm({
|
||||
onSubmit,
|
||||
onCancel,
|
||||
initialData,
|
||||
parentId,
|
||||
}: {
|
||||
onSubmit: (data: ComponentFormData) => void
|
||||
onCancel: () => void
|
||||
initialData?: Component | null
|
||||
parentId?: string | null
|
||||
}) {
|
||||
const [formData, setFormData] = useState<ComponentFormData>({
|
||||
name: initialData?.name || '',
|
||||
type: initialData?.type || 'SW',
|
||||
version: initialData?.version || '',
|
||||
description: initialData?.description || '',
|
||||
safety_relevant: initialData?.safety_relevant || false,
|
||||
parent_id: parentId || initialData?.parent_id || null,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
{initialData ? 'Komponente bearbeiten' : parentId ? 'Unterkomponente hinzufuegen' : 'Neue Komponente'}
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="z.B. Bildverarbeitungsmodul"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Typ</label>
|
||||
<select
|
||||
value={formData.type}
|
||||
onChange={(e) => setFormData({ ...formData, type: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
>
|
||||
{COMPONENT_TYPES.map((t) => (
|
||||
<option key={t.value} value={t.value}>{t.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Version</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.version}
|
||||
onChange={(e) => setFormData({ ...formData, version: e.target.value })}
|
||||
placeholder="z.B. 1.2.0"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 pt-6">
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.safety_relevant}
|
||||
onChange={(e) => setFormData({ ...formData, safety_relevant: e.target.checked })}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-9 h-5 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-purple-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-red-500" />
|
||||
</label>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Sicherheitsrelevant</span>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Beschreibung</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="Kurze Beschreibung der Komponente..."
|
||||
rows={2}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => onSubmit(formData)}
|
||||
disabled={!formData.name}
|
||||
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
|
||||
formData.name
|
||||
? 'bg-purple-600 text-white hover:bg-purple-700'
|
||||
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
{initialData ? 'Aktualisieren' : 'Hinzufuegen'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function buildTree(components: Component[]): Component[] {
|
||||
const map = new Map<string, Component>()
|
||||
const roots: Component[] = []
|
||||
|
||||
components.forEach((c) => {
|
||||
map.set(c.id, { ...c, children: [] })
|
||||
})
|
||||
|
||||
components.forEach((c) => {
|
||||
const node = map.get(c.id)!
|
||||
if (c.parent_id && map.has(c.parent_id)) {
|
||||
map.get(c.parent_id)!.children.push(node)
|
||||
} else {
|
||||
roots.push(node)
|
||||
}
|
||||
})
|
||||
|
||||
return roots
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Component Library Modal (Phase 5)
|
||||
// ============================================================================
|
||||
|
||||
function ComponentLibraryModal({
|
||||
onAdd,
|
||||
onClose,
|
||||
}: {
|
||||
onAdd: (components: LibraryComponent[], energySources: EnergySource[]) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [libraryComponents, setLibraryComponents] = useState<LibraryComponent[]>([])
|
||||
const [energySources, setEnergySources] = useState<EnergySource[]>([])
|
||||
const [selectedComponents, setSelectedComponents] = useState<Set<string>>(new Set())
|
||||
const [selectedEnergySources, setSelectedEnergySources] = useState<Set<string>>(new Set())
|
||||
const [search, setSearch] = useState('')
|
||||
const [filterCategory, setFilterCategory] = useState('')
|
||||
const [activeTab, setActiveTab] = useState<'components' | 'energy'>('components')
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
try {
|
||||
const [compRes, enRes] = await Promise.all([
|
||||
fetch('/api/sdk/v1/iace/component-library'),
|
||||
fetch('/api/sdk/v1/iace/energy-sources'),
|
||||
])
|
||||
if (compRes.ok) {
|
||||
const json = await compRes.json()
|
||||
setLibraryComponents(json.components || [])
|
||||
}
|
||||
if (enRes.ok) {
|
||||
const json = await enRes.json()
|
||||
setEnergySources(json.energy_sources || [])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch library:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
fetchData()
|
||||
}, [])
|
||||
|
||||
function toggleComponent(id: string) {
|
||||
setSelectedComponents(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function toggleEnergySource(id: string) {
|
||||
setSelectedEnergySources(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function toggleAllInCategory(category: string) {
|
||||
const items = libraryComponents.filter(c => c.category === category)
|
||||
const allIds = items.map(i => i.id)
|
||||
const allSelected = allIds.every(id => selectedComponents.has(id))
|
||||
setSelectedComponents(prev => {
|
||||
const next = new Set(prev)
|
||||
allIds.forEach(id => allSelected ? next.delete(id) : next.add(id))
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function handleAdd() {
|
||||
const selComps = libraryComponents.filter(c => selectedComponents.has(c.id))
|
||||
const selEnergy = energySources.filter(e => selectedEnergySources.has(e.id))
|
||||
onAdd(selComps, selEnergy)
|
||||
}
|
||||
|
||||
const filtered = libraryComponents.filter(c => {
|
||||
if (filterCategory && c.category !== filterCategory) return false
|
||||
if (search) {
|
||||
const q = search.toLowerCase()
|
||||
return c.name_de.toLowerCase().includes(q) || c.name_en.toLowerCase().includes(q) || c.description_de.toLowerCase().includes(q)
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
const grouped = filtered.reduce<Record<string, LibraryComponent[]>>((acc, c) => {
|
||||
if (!acc[c.category]) acc[c.category] = []
|
||||
acc[c.category].push(c)
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
const categories = Object.keys(LIBRARY_CATEGORIES)
|
||||
const totalSelected = selectedComponents.size + selectedEnergySources.size
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl p-8 text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600 mx-auto" />
|
||||
<p className="mt-3 text-sm text-gray-500">Bibliothek wird geladen...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl w-full max-w-4xl max-h-[85vh] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Komponentenbibliothek</h3>
|
||||
<button onClick={onClose} className="p-1 text-gray-400 hover:text-gray-600 rounded">
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-2 mb-4">
|
||||
<button
|
||||
onClick={() => setActiveTab('components')}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||
activeTab === 'components' ? 'bg-purple-100 text-purple-700' : 'text-gray-500 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
Komponenten ({libraryComponents.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('energy')}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||
activeTab === 'energy' ? 'bg-purple-100 text-purple-700' : 'text-gray-500 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
Energiequellen ({energySources.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === 'components' && (
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder="Suchen..."
|
||||
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
/>
|
||||
<select
|
||||
value={filterCategory}
|
||||
onChange={e => setFilterCategory(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
>
|
||||
<option value="">Alle Kategorien</option>
|
||||
{categories.map(cat => (
|
||||
<option key={cat} value={cat}>{LIBRARY_CATEGORIES[cat]}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
{activeTab === 'components' ? (
|
||||
<div className="space-y-4">
|
||||
{Object.entries(grouped)
|
||||
.sort(([a], [b]) => categories.indexOf(a) - categories.indexOf(b))
|
||||
.map(([category, items]) => (
|
||||
<div key={category}>
|
||||
<div className="flex items-center gap-2 mb-2 sticky top-0 bg-white dark:bg-gray-800 py-1 z-10">
|
||||
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
{LIBRARY_CATEGORIES[category] || category}
|
||||
</h4>
|
||||
<span className="text-xs text-gray-400">({items.length})</span>
|
||||
<button
|
||||
onClick={() => toggleAllInCategory(category)}
|
||||
className="text-xs text-purple-600 hover:text-purple-700 ml-auto"
|
||||
>
|
||||
{items.every(i => selectedComponents.has(i.id)) ? 'Alle abwaehlen' : 'Alle waehlen'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{items.map(comp => (
|
||||
<label
|
||||
key={comp.id}
|
||||
className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
|
||||
selectedComponents.has(comp.id)
|
||||
? 'border-purple-400 bg-purple-50 dark:bg-purple-900/20'
|
||||
: 'border-gray-200 hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-750'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedComponents.has(comp.id)}
|
||||
onChange={() => toggleComponent(comp.id)}
|
||||
className="mt-0.5 accent-purple-600"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-mono text-gray-400">{comp.id}</span>
|
||||
<ComponentTypeIcon type={comp.maps_to_component_type} />
|
||||
</div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">{comp.name_de}</div>
|
||||
{comp.description_de && (
|
||||
<div className="text-xs text-gray-500 mt-0.5 line-clamp-2">{comp.description_de}</div>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-500">Keine Komponenten gefunden</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{energySources.map(es => (
|
||||
<label
|
||||
key={es.id}
|
||||
className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
|
||||
selectedEnergySources.has(es.id)
|
||||
? 'border-purple-400 bg-purple-50 dark:bg-purple-900/20'
|
||||
: 'border-gray-200 hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-750'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedEnergySources.has(es.id)}
|
||||
onChange={() => toggleEnergySource(es.id)}
|
||||
className="mt-0.5 accent-purple-600"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-mono text-gray-400">{es.id}</span>
|
||||
</div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">{es.name_de}</div>
|
||||
{es.description_de && (
|
||||
<div className="text-xs text-gray-500 mt-0.5 line-clamp-2">{es.description_de}</div>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<span className="text-sm text-gray-500">
|
||||
{selectedComponents.size} Komponenten, {selectedEnergySources.size} Energiequellen ausgewaehlt
|
||||
</span>
|
||||
<div className="flex gap-3">
|
||||
<button onClick={onClose} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
disabled={totalSelected === 0}
|
||||
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
|
||||
totalSelected > 0
|
||||
? 'bg-purple-600 text-white hover:bg-purple-700'
|
||||
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
{totalSelected > 0 ? `${totalSelected} hinzufuegen` : 'Auswaehlen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main Page
|
||||
// ============================================================================
|
||||
import { Component } from './_components/types'
|
||||
import { ComponentTreeNode } from './_components/ComponentTreeNode'
|
||||
import { ComponentForm } from './_components/ComponentForm'
|
||||
import { ComponentLibraryModal } from './_components/ComponentLibraryModal'
|
||||
import { useComponents } from './_hooks/useComponents'
|
||||
|
||||
export default function ComponentsPage() {
|
||||
const params = useParams()
|
||||
const projectId = params.projectId as string
|
||||
const [components, setComponents] = useState<Component[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const { loading, tree, handleSubmit, handleDelete, handleAddFromLibrary } = useComponents(projectId)
|
||||
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [editingComponent, setEditingComponent] = useState<Component | null>(null)
|
||||
const [addingParentId, setAddingParentId] = useState<string | null>(null)
|
||||
const [showLibrary, setShowLibrary] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetchComponents()
|
||||
}, [projectId])
|
||||
|
||||
async function fetchComponents() {
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/components`)
|
||||
if (res.ok) {
|
||||
const json = await res.json()
|
||||
setComponents(json.components || json || [])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch components:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(data: ComponentFormData) {
|
||||
try {
|
||||
const url = editingComponent
|
||||
? `/api/sdk/v1/iace/projects/${projectId}/components/${editingComponent.id}`
|
||||
: `/api/sdk/v1/iace/projects/${projectId}/components`
|
||||
const method = editingComponent ? 'PUT' : 'POST'
|
||||
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
if (res.ok) {
|
||||
setShowForm(false)
|
||||
setEditingComponent(null)
|
||||
setAddingParentId(null)
|
||||
await fetchComponents()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to save component:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
if (!confirm('Komponente wirklich loeschen? Unterkomponenten werden ebenfalls entfernt.')) return
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/components/${id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
if (res.ok) {
|
||||
await fetchComponents()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to delete component:', err)
|
||||
}
|
||||
}
|
||||
|
||||
function handleEdit(component: Component) {
|
||||
setEditingComponent(component)
|
||||
setAddingParentId(null)
|
||||
@@ -694,33 +31,11 @@ export default function ComponentsPage() {
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
async function handleAddFromLibrary(libraryComps: LibraryComponent[], energySrcs: EnergySource[]) {
|
||||
setShowLibrary(false)
|
||||
const energySourceIds = energySrcs.map(e => e.id)
|
||||
|
||||
for (const comp of libraryComps) {
|
||||
try {
|
||||
await fetch(`/api/sdk/v1/iace/projects/${projectId}/components`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: comp.name_de,
|
||||
type: comp.maps_to_component_type,
|
||||
description: comp.description_de,
|
||||
safety_relevant: false,
|
||||
library_component_id: comp.id,
|
||||
energy_source_ids: energySourceIds,
|
||||
tags: comp.tags,
|
||||
}),
|
||||
})
|
||||
} catch (err) {
|
||||
console.error(`Failed to add component ${comp.id}:`, err)
|
||||
function resetForm() {
|
||||
setShowForm(false)
|
||||
setEditingComponent(null)
|
||||
setAddingParentId(null)
|
||||
}
|
||||
}
|
||||
await fetchComponents()
|
||||
}
|
||||
|
||||
const tree = buildTree(components)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -752,11 +67,7 @@ export default function ComponentsPage() {
|
||||
Aus Bibliothek waehlen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowForm(true)
|
||||
setEditingComponent(null)
|
||||
setAddingParentId(null)
|
||||
}}
|
||||
onClick={() => { setShowForm(true); setEditingComponent(null); setAddingParentId(null) }}
|
||||
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">
|
||||
@@ -768,23 +79,20 @@ export default function ComponentsPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Library Modal */}
|
||||
{showLibrary && (
|
||||
<ComponentLibraryModal
|
||||
onAdd={handleAddFromLibrary}
|
||||
onAdd={async (comps, energy) => { setShowLibrary(false); await handleAddFromLibrary(comps, energy) }}
|
||||
onClose={() => setShowLibrary(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Form */}
|
||||
{showForm && (
|
||||
<ComponentForm
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={() => {
|
||||
setShowForm(false)
|
||||
setEditingComponent(null)
|
||||
setAddingParentId(null)
|
||||
onSubmit={async (data) => {
|
||||
const ok = await handleSubmit(data, editingComponent?.id)
|
||||
if (ok) resetForm()
|
||||
}}
|
||||
onCancel={resetForm}
|
||||
initialData={editingComponent}
|
||||
parentId={addingParentId}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
'use client'
|
||||
|
||||
export function HierarchyWarning({ onDismiss }: { onDismiss: () => void }) {
|
||||
return (
|
||||
<div className="bg-amber-50 border border-amber-300 rounded-xl p-4 flex items-start gap-3">
|
||||
<svg className="w-6 h-6 text-amber-600 flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<div className="flex-1">
|
||||
<h4 className="text-sm font-semibold text-amber-800">Hierarchie-Warnung: Massnahmen vom Typ "Information"</h4>
|
||||
<p className="text-sm text-amber-700 mt-1">
|
||||
Hinweismassnahmen (Stufe 3) duerfen <strong>nicht als Primaermassnahme</strong> akzeptiert werden, wenn konstruktive
|
||||
(Stufe 1) oder technische (Stufe 2) Massnahmen moeglich und zumutbar sind. Pruefen Sie, ob hoeherwertige
|
||||
Massnahmen ergaenzt werden koennen.
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={onDismiss} className="text-amber-400 hover:text-amber-600 transition-colors">
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { ProtectiveMeasure } from './types'
|
||||
|
||||
export function MeasuresLibraryModal({
|
||||
measures,
|
||||
onSelect,
|
||||
onClose,
|
||||
filterType,
|
||||
}: {
|
||||
measures: ProtectiveMeasure[]
|
||||
onSelect: (measure: ProtectiveMeasure) => void
|
||||
onClose: () => void
|
||||
filterType?: string
|
||||
}) {
|
||||
const [search, setSearch] = useState('')
|
||||
const [selectedSubType, setSelectedSubType] = useState('')
|
||||
|
||||
const filtered = measures.filter((m) => {
|
||||
if (filterType && m.reduction_type !== filterType) return false
|
||||
if (selectedSubType && m.sub_type !== selectedSubType) return false
|
||||
if (search) {
|
||||
const q = search.toLowerCase()
|
||||
return m.name.toLowerCase().includes(q) || m.description.toLowerCase().includes(q)
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
const subTypes = [...new Set(measures.filter((m) => !filterType || m.reduction_type === filterType).map((m) => m.sub_type))].filter(Boolean)
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl w-full max-w-3xl max-h-[80vh] flex flex-col">
|
||||
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Massnahmen-Bibliothek</h3>
|
||||
<button onClick={onClose} className="p-1 text-gray-400 hover:text-gray-600 rounded transition-colors">
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Massnahme suchen..."
|
||||
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
/>
|
||||
{subTypes.length > 1 && (
|
||||
<select
|
||||
value={selectedSubType}
|
||||
onChange={(e) => setSelectedSubType(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white text-sm"
|
||||
>
|
||||
<option value="">Alle Sub-Typen</option>
|
||||
{subTypes.map((st) => (
|
||||
<option key={st} value={st}>{st}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-gray-500">{filtered.length} Massnahmen</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-3">
|
||||
{filtered.map((m) => (
|
||||
<div
|
||||
key={m.id}
|
||||
className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:border-purple-300 hover:bg-purple-50/30 transition-colors cursor-pointer"
|
||||
onClick={() => onSelect(m)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs font-mono text-gray-400">{m.id}</span>
|
||||
{m.sub_type && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-gray-100 text-gray-600">{m.sub_type}</span>
|
||||
)}
|
||||
</div>
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-white">{m.name}</h4>
|
||||
<p className="text-xs text-gray-500 mt-1">{m.description}</p>
|
||||
{m.examples && m.examples.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{m.examples.map((ex, i) => (
|
||||
<span key={i} className="text-xs px-1.5 py-0.5 rounded bg-purple-50 text-purple-600">
|
||||
{ex}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button className="ml-3 px-3 py-1.5 text-xs bg-purple-100 text-purple-700 rounded-lg hover:bg-purple-200 transition-colors flex-shrink-0">
|
||||
Uebernehmen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-500">Keine Massnahmen gefunden</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
'use client'
|
||||
|
||||
import { Mitigation } from './types'
|
||||
import { StatusBadge } from './StatusBadge'
|
||||
|
||||
export function MitigationCard({
|
||||
mitigation,
|
||||
onVerify,
|
||||
onDelete,
|
||||
}: {
|
||||
mitigation: Mitigation
|
||||
onVerify: (id: string) => void
|
||||
onDelete: (id: string) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-white">{mitigation.title}</h4>
|
||||
{mitigation.title.startsWith('Auto:') && (
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-green-100 text-green-700">
|
||||
Auto
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<StatusBadge status={mitigation.status} />
|
||||
</div>
|
||||
{mitigation.description && (
|
||||
<p className="text-xs text-gray-500 mb-3">{mitigation.description}</p>
|
||||
)}
|
||||
{mitigation.linked_hazard_names.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{mitigation.linked_hazard_names.map((name, i) => (
|
||||
<span key={i} className="inline-flex items-center px-1.5 py-0.5 rounded text-xs bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400">
|
||||
{name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{mitigation.status !== 'verified' && (
|
||||
<button
|
||||
onClick={() => onVerify(mitigation.id)}
|
||||
className="text-xs px-2.5 py-1 bg-green-50 text-green-700 border border-green-200 rounded-lg hover:bg-green-100 transition-colors"
|
||||
>
|
||||
Verifizieren
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onDelete(mitigation.id)}
|
||||
className="text-xs px-2.5 py-1 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||
>
|
||||
Loeschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Hazard, MitigationFormData } from './types'
|
||||
|
||||
export function MitigationForm({
|
||||
onSubmit,
|
||||
onCancel,
|
||||
hazards,
|
||||
preselectedType,
|
||||
onOpenLibrary,
|
||||
}: {
|
||||
onSubmit: (data: MitigationFormData) => void
|
||||
onCancel: () => void
|
||||
hazards: Hazard[]
|
||||
preselectedType?: 'design' | 'protection' | 'information'
|
||||
onOpenLibrary: (type?: string) => void
|
||||
}) {
|
||||
const [formData, setFormData] = useState<MitigationFormData>({
|
||||
title: '',
|
||||
description: '',
|
||||
reduction_type: preselectedType || 'design',
|
||||
linked_hazard_ids: [],
|
||||
})
|
||||
|
||||
function toggleHazard(id: string) {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
linked_hazard_ids: prev.linked_hazard_ids.includes(id)
|
||||
? prev.linked_hazard_ids.filter((h) => h !== id)
|
||||
: [...prev.linked_hazard_ids, id],
|
||||
}))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Neue Massnahme</h3>
|
||||
<button
|
||||
onClick={() => onOpenLibrary(formData.reduction_type)}
|
||||
className="text-sm px-3 py-1.5 bg-purple-50 text-purple-700 border border-purple-200 rounded-lg hover:bg-purple-100 transition-colors"
|
||||
>
|
||||
Aus Bibliothek waehlen
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Titel *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||
placeholder="z.B. Lichtvorhang an Gefahrenstelle"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Reduktionstyp</label>
|
||||
<select
|
||||
value={formData.reduction_type}
|
||||
onChange={(e) => setFormData({ ...formData, reduction_type: e.target.value as MitigationFormData['reduction_type'] })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
>
|
||||
<option value="design">Stufe 1: Design - Inhaerent sichere Konstruktion</option>
|
||||
<option value="protection">Stufe 2: Schutz - Technische Schutzmassnahmen</option>
|
||||
<option value="information">Stufe 3: Information - Hinweise und Schulungen</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Beschreibung</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
rows={2}
|
||||
placeholder="Detaillierte Beschreibung der Massnahme..."
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
{hazards.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Verknuepfte Gefaehrdungen</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{hazards.map((h) => (
|
||||
<button
|
||||
key={h.id}
|
||||
onClick={() => toggleHazard(h.id)}
|
||||
className={`px-3 py-1.5 text-xs rounded-lg border transition-colors ${
|
||||
formData.linked_hazard_ids.includes(h.id)
|
||||
? 'border-purple-400 bg-purple-50 text-purple-700'
|
||||
: 'border-gray-200 bg-white text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{h.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4 flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => onSubmit(formData)}
|
||||
disabled={!formData.title}
|
||||
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
|
||||
formData.title
|
||||
? 'bg-purple-600 text-white hover:bg-purple-700'
|
||||
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
Hinzufuegen
|
||||
</button>
|
||||
<button onClick={onCancel} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
'use client'
|
||||
|
||||
export function StatusBadge({ status }: { status: string }) {
|
||||
const colors: Record<string, string> = {
|
||||
planned: 'bg-gray-100 text-gray-700',
|
||||
implemented: 'bg-blue-100 text-blue-700',
|
||||
verified: 'bg-green-100 text-green-700',
|
||||
}
|
||||
const labels: Record<string, string> = {
|
||||
planned: 'Geplant',
|
||||
implemented: 'Umgesetzt',
|
||||
verified: 'Verifiziert',
|
||||
}
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${colors[status] || colors.planned}`}>
|
||||
{labels[status] || status}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Hazard, SuggestedMeasure, REDUCTION_TYPES } from './types'
|
||||
|
||||
export function SuggestMeasuresModal({
|
||||
hazards,
|
||||
projectId,
|
||||
onAddMeasure,
|
||||
onClose,
|
||||
}: {
|
||||
hazards: Hazard[]
|
||||
projectId: string
|
||||
onAddMeasure: (title: string, description: string, reductionType: string, hazardId: string) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [selectedHazard, setSelectedHazard] = useState<string>('')
|
||||
const [suggested, setSuggested] = useState<SuggestedMeasure[]>([])
|
||||
const [loadingSuggestions, setLoadingSuggestions] = useState(false)
|
||||
|
||||
const riskColors: Record<string, string> = {
|
||||
not_acceptable: 'border-red-400 bg-red-50',
|
||||
very_high: 'border-red-300 bg-red-50',
|
||||
critical: 'border-red-300 bg-red-50',
|
||||
high: 'border-orange-300 bg-orange-50',
|
||||
medium: 'border-yellow-300 bg-yellow-50',
|
||||
low: 'border-green-300 bg-green-50',
|
||||
}
|
||||
|
||||
async function handleSelectHazard(hazardId: string) {
|
||||
setSelectedHazard(hazardId)
|
||||
setSuggested([])
|
||||
if (!hazardId) return
|
||||
|
||||
setLoadingSuggestions(true)
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/hazards/${hazardId}/suggest-measures`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
if (res.ok) {
|
||||
const json = await res.json()
|
||||
setSuggested(json.suggested_measures || [])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to suggest measures:', err)
|
||||
} finally {
|
||||
setLoadingSuggestions(false)
|
||||
}
|
||||
}
|
||||
|
||||
const groupedByType = {
|
||||
design: suggested.filter(m => m.reduction_type === 'design'),
|
||||
protection: suggested.filter(m => m.reduction_type === 'protection'),
|
||||
information: suggested.filter(m => m.reduction_type === 'information'),
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl w-full max-w-3xl max-h-[85vh] flex flex-col">
|
||||
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Massnahmen-Vorschlaege</h3>
|
||||
<button onClick={onClose} className="p-1 text-gray-400 hover:text-gray-600 rounded">
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mb-3">
|
||||
Waehlen Sie eine Gefaehrdung, um passende Massnahmen vorgeschlagen zu bekommen.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{hazards.map(h => (
|
||||
<button
|
||||
key={h.id}
|
||||
onClick={() => handleSelectHazard(h.id)}
|
||||
className={`px-3 py-1.5 text-xs rounded-lg border transition-colors ${
|
||||
selectedHazard === h.id
|
||||
? 'border-purple-400 bg-purple-50 text-purple-700 font-medium'
|
||||
: `${riskColors[h.risk_level] || 'border-gray-200 bg-white'} text-gray-700 hover:border-purple-300`
|
||||
}`}
|
||||
>
|
||||
{h.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
{loadingSuggestions ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
|
||||
</div>
|
||||
) : suggested.length > 0 ? (
|
||||
<div className="space-y-6">
|
||||
{(['design', 'protection', 'information'] as const).map(type => {
|
||||
const items = groupedByType[type]
|
||||
if (items.length === 0) return null
|
||||
const config = REDUCTION_TYPES[type]
|
||||
return (
|
||||
<div key={type}>
|
||||
<div className={`flex items-center gap-2 px-3 py-2 rounded-lg ${config.headerColor} mb-3`}>
|
||||
{config.icon}
|
||||
<span className="text-sm font-semibold">{config.label}</span>
|
||||
<span className="ml-auto text-sm font-bold">{items.length}</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{items.map(m => (
|
||||
<div key={m.id} className="border border-gray-200 rounded-lg p-3 hover:bg-gray-50 transition-colors">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs font-mono text-gray-400">{m.id}</span>
|
||||
{m.sub_type && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-gray-100 text-gray-600">{m.sub_type}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">{m.name}</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">{m.description}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onAddMeasure(m.name, m.description, m.reduction_type, selectedHazard)}
|
||||
className="ml-3 px-3 py-1.5 text-xs bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors flex-shrink-0"
|
||||
>
|
||||
Uebernehmen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : selectedHazard ? (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
Keine Vorschlaege fuer diese Gefaehrdung gefunden.
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
Waehlen Sie eine Gefaehrdung aus, um Vorschlaege zu erhalten.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import React from 'react'
|
||||
|
||||
export interface Mitigation {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
reduction_type: 'design' | 'protection' | 'information'
|
||||
status: 'planned' | 'implemented' | 'verified'
|
||||
linked_hazard_ids: string[]
|
||||
linked_hazard_names: string[]
|
||||
created_at: string
|
||||
verified_at: string | null
|
||||
verified_by: string | null
|
||||
source?: string
|
||||
}
|
||||
|
||||
export interface Hazard {
|
||||
id: string
|
||||
name: string
|
||||
risk_level: string
|
||||
category?: string
|
||||
}
|
||||
|
||||
export interface ProtectiveMeasure {
|
||||
id: string
|
||||
reduction_type: string
|
||||
sub_type: string
|
||||
name: string
|
||||
description: string
|
||||
hazard_category: string
|
||||
examples: string[]
|
||||
}
|
||||
|
||||
export interface SuggestedMeasure {
|
||||
id: string
|
||||
reduction_type: string
|
||||
sub_type: string
|
||||
name: string
|
||||
description: string
|
||||
hazard_category: string
|
||||
examples: string[]
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
export interface MitigationFormData {
|
||||
title: string
|
||||
description: string
|
||||
reduction_type: 'design' | 'protection' | 'information'
|
||||
linked_hazard_ids: string[]
|
||||
}
|
||||
|
||||
export const REDUCTION_TYPES = {
|
||||
design: {
|
||||
label: 'Stufe 1: Design',
|
||||
description: 'Inhaerent sichere Konstruktion',
|
||||
color: 'border-blue-200 bg-blue-50',
|
||||
headerColor: 'bg-blue-100 text-blue-800',
|
||||
subTypes: [
|
||||
{ value: 'geometry', label: 'Geometrie & Anordnung' },
|
||||
{ value: 'force_energy', label: 'Kraft & Energie' },
|
||||
{ value: 'material', label: 'Material & Stabilitaet' },
|
||||
{ value: 'ergonomics', label: 'Ergonomie' },
|
||||
{ value: 'control_design', label: 'Steuerungstechnik' },
|
||||
{ value: 'fluid_design', label: 'Pneumatik / Hydraulik' },
|
||||
],
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 4a2 2 0 114 0v1a1 1 0 001 1h3a1 1 0 011 1v3a1 1 0 01-1 1h-1a2 2 0 100 4h1a1 1 0 011 1v3a1 1 0 01-1 1h-3a1 1 0 01-1-1v-1a2 2 0 10-4 0v1a1 1 0 01-1 1H7a1 1 0 01-1-1v-3a1 1 0 00-1-1H4a2 2 0 110-4h1a1 1 0 001-1V7a1 1 0 011-1h3a1 1 0 001-1V4z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
protection: {
|
||||
label: 'Stufe 2: Schutz',
|
||||
description: 'Technische Schutzmassnahmen',
|
||||
color: 'border-green-200 bg-green-50',
|
||||
headerColor: 'bg-green-100 text-green-800',
|
||||
subTypes: [
|
||||
{ value: 'fixed_guard', label: 'Feststehende Schutzeinrichtung' },
|
||||
{ value: 'movable_guard', label: 'Bewegliche Schutzeinrichtung' },
|
||||
{ value: 'electro_sensitive', label: 'Optoelektronisch' },
|
||||
{ value: 'pressure_sensitive', label: 'Druckempfindlich' },
|
||||
{ value: 'emergency_stop', label: 'Not-Halt' },
|
||||
{ value: 'electrical_protection', label: 'Elektrischer Schutz' },
|
||||
{ value: 'thermal_protection', label: 'Thermischer Schutz' },
|
||||
{ value: 'fluid_protection', label: 'Hydraulik/Pneumatik-Schutz' },
|
||||
{ value: 'extraction', label: 'Absaugung / Kapselung' },
|
||||
],
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
information: {
|
||||
label: 'Stufe 3: Information',
|
||||
description: 'Hinweise und Schulungen',
|
||||
color: 'border-yellow-200 bg-yellow-50',
|
||||
headerColor: 'bg-yellow-100 text-yellow-800',
|
||||
subTypes: [
|
||||
{ value: 'signage', label: 'Beschilderung & Kennzeichnung' },
|
||||
{ value: 'manual', label: 'Betriebsanleitung' },
|
||||
{ value: 'training', label: 'Schulung & Unterweisung' },
|
||||
{ value: 'ppe', label: 'PSA (Schutzausruestung)' },
|
||||
{ value: 'organizational', label: 'Organisatorisch' },
|
||||
{ value: 'marking', label: 'Markierung & Codierung' },
|
||||
],
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Mitigation, Hazard, ProtectiveMeasure, MitigationFormData } from '../_components/types'
|
||||
|
||||
export function useMitigations(projectId: string) {
|
||||
const [mitigations, setMitigations] = useState<Mitigation[]>([])
|
||||
const [hazards, setHazards] = useState<Hazard[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [hierarchyWarning, setHierarchyWarning] = useState<boolean>(false)
|
||||
const [measures, setMeasures] = useState<ProtectiveMeasure[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [projectId]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
async function fetchData() {
|
||||
try {
|
||||
const [mitRes, hazRes] = await Promise.all([
|
||||
fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations`),
|
||||
fetch(`/api/sdk/v1/iace/projects/${projectId}/hazards`),
|
||||
])
|
||||
if (mitRes.ok) {
|
||||
const json = await mitRes.json()
|
||||
const mits = json.mitigations || json || []
|
||||
setMitigations(mits)
|
||||
validateHierarchy(mits)
|
||||
}
|
||||
if (hazRes.ok) {
|
||||
const json = await hazRes.json()
|
||||
setHazards((json.hazards || json || []).map((h: Hazard) => ({ id: h.id, name: h.name, risk_level: h.risk_level, category: h.category })))
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch data:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function validateHierarchy(mits: Mitigation[]) {
|
||||
if (mits.length === 0) return
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/validate-mitigation-hierarchy`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
mitigations: mits.map((m) => ({
|
||||
reduction_type: m.reduction_type,
|
||||
linked_hazard_ids: m.linked_hazard_ids,
|
||||
})),
|
||||
}),
|
||||
})
|
||||
if (res.ok) {
|
||||
const json = await res.json()
|
||||
setHierarchyWarning(json.has_warning === true)
|
||||
}
|
||||
} catch {
|
||||
// Non-critical, ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchMeasuresLibrary(type?: string) {
|
||||
try {
|
||||
const url = type
|
||||
? `/api/sdk/v1/iace/protective-measures-library?reduction_type=${type}`
|
||||
: '/api/sdk/v1/iace/protective-measures-library'
|
||||
const res = await fetch(url)
|
||||
if (res.ok) {
|
||||
const json = await res.json()
|
||||
setMeasures(json.protective_measures || [])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch measures library:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(data: MitigationFormData) {
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
if (res.ok) {
|
||||
await fetchData()
|
||||
return true
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to add mitigation:', err)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function handleAddSuggestedMeasure(title: string, description: string, reductionType: string, hazardId: string) {
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title, description, reduction_type: reductionType, linked_hazard_ids: [hazardId] }),
|
||||
})
|
||||
if (res.ok) await fetchData()
|
||||
} catch (err) {
|
||||
console.error('Failed to add suggested measure:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleVerify(id: string) {
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations/${id}/verify`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
if (res.ok) await fetchData()
|
||||
} catch (err) {
|
||||
console.error('Failed to verify mitigation:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
if (!confirm('Massnahme wirklich loeschen?')) return
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations/${id}`, { method: 'DELETE' })
|
||||
if (res.ok) await fetchData()
|
||||
} catch (err) {
|
||||
console.error('Failed to delete mitigation:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const byType = {
|
||||
design: mitigations.filter((m) => m.reduction_type === 'design'),
|
||||
protection: mitigations.filter((m) => m.reduction_type === 'protection'),
|
||||
information: mitigations.filter((m) => m.reduction_type === 'information'),
|
||||
}
|
||||
|
||||
return {
|
||||
mitigations, hazards, loading, hierarchyWarning, setHierarchyWarning,
|
||||
measures, byType,
|
||||
fetchMeasuresLibrary, handleSubmit, handleAddSuggestedMeasure, handleVerify, handleDelete,
|
||||
}
|
||||
}
|
||||
@@ -1,665 +1,32 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
|
||||
interface Mitigation {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
reduction_type: 'design' | 'protection' | 'information'
|
||||
status: 'planned' | 'implemented' | 'verified'
|
||||
linked_hazard_ids: string[]
|
||||
linked_hazard_names: string[]
|
||||
created_at: string
|
||||
verified_at: string | null
|
||||
verified_by: string | null
|
||||
source?: string
|
||||
}
|
||||
|
||||
interface Hazard {
|
||||
id: string
|
||||
name: string
|
||||
risk_level: string
|
||||
category?: string
|
||||
}
|
||||
|
||||
interface ProtectiveMeasure {
|
||||
id: string
|
||||
reduction_type: string
|
||||
sub_type: string
|
||||
name: string
|
||||
description: string
|
||||
hazard_category: string
|
||||
examples: string[]
|
||||
}
|
||||
|
||||
interface SuggestedMeasure {
|
||||
id: string
|
||||
reduction_type: string
|
||||
sub_type: string
|
||||
name: string
|
||||
description: string
|
||||
hazard_category: string
|
||||
examples: string[]
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
const REDUCTION_TYPES = {
|
||||
design: {
|
||||
label: 'Stufe 1: Design',
|
||||
description: 'Inhaerent sichere Konstruktion',
|
||||
color: 'border-blue-200 bg-blue-50',
|
||||
headerColor: 'bg-blue-100 text-blue-800',
|
||||
subTypes: [
|
||||
{ value: 'geometry', label: 'Geometrie & Anordnung' },
|
||||
{ value: 'force_energy', label: 'Kraft & Energie' },
|
||||
{ value: 'material', label: 'Material & Stabilitaet' },
|
||||
{ value: 'ergonomics', label: 'Ergonomie' },
|
||||
{ value: 'control_design', label: 'Steuerungstechnik' },
|
||||
{ value: 'fluid_design', label: 'Pneumatik / Hydraulik' },
|
||||
],
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 4a2 2 0 114 0v1a1 1 0 001 1h3a1 1 0 011 1v3a1 1 0 01-1 1h-1a2 2 0 100 4h1a1 1 0 011 1v3a1 1 0 01-1 1h-3a1 1 0 01-1-1v-1a2 2 0 10-4 0v1a1 1 0 01-1 1H7a1 1 0 01-1-1v-3a1 1 0 00-1-1H4a2 2 0 110-4h1a1 1 0 001-1V7a1 1 0 011-1h3a1 1 0 001-1V4z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
protection: {
|
||||
label: 'Stufe 2: Schutz',
|
||||
description: 'Technische Schutzmassnahmen',
|
||||
color: 'border-green-200 bg-green-50',
|
||||
headerColor: 'bg-green-100 text-green-800',
|
||||
subTypes: [
|
||||
{ value: 'fixed_guard', label: 'Feststehende Schutzeinrichtung' },
|
||||
{ value: 'movable_guard', label: 'Bewegliche Schutzeinrichtung' },
|
||||
{ value: 'electro_sensitive', label: 'Optoelektronisch' },
|
||||
{ value: 'pressure_sensitive', label: 'Druckempfindlich' },
|
||||
{ value: 'emergency_stop', label: 'Not-Halt' },
|
||||
{ value: 'electrical_protection', label: 'Elektrischer Schutz' },
|
||||
{ value: 'thermal_protection', label: 'Thermischer Schutz' },
|
||||
{ value: 'fluid_protection', label: 'Hydraulik/Pneumatik-Schutz' },
|
||||
{ value: 'extraction', label: 'Absaugung / Kapselung' },
|
||||
],
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
information: {
|
||||
label: 'Stufe 3: Information',
|
||||
description: 'Hinweise und Schulungen',
|
||||
color: 'border-yellow-200 bg-yellow-50',
|
||||
headerColor: 'bg-yellow-100 text-yellow-800',
|
||||
subTypes: [
|
||||
{ value: 'signage', label: 'Beschilderung & Kennzeichnung' },
|
||||
{ value: 'manual', label: 'Betriebsanleitung' },
|
||||
{ value: 'training', label: 'Schulung & Unterweisung' },
|
||||
{ value: 'ppe', label: 'PSA (Schutzausruestung)' },
|
||||
{ value: 'organizational', label: 'Organisatorisch' },
|
||||
{ value: 'marking', label: 'Markierung & Codierung' },
|
||||
],
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const colors: Record<string, string> = {
|
||||
planned: 'bg-gray-100 text-gray-700',
|
||||
implemented: 'bg-blue-100 text-blue-700',
|
||||
verified: 'bg-green-100 text-green-700',
|
||||
}
|
||||
const labels: Record<string, string> = {
|
||||
planned: 'Geplant',
|
||||
implemented: 'Umgesetzt',
|
||||
verified: 'Verifiziert',
|
||||
}
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${colors[status] || colors.planned}`}>
|
||||
{labels[status] || status}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function HierarchyWarning({ onDismiss }: { onDismiss: () => void }) {
|
||||
return (
|
||||
<div className="bg-amber-50 border border-amber-300 rounded-xl p-4 flex items-start gap-3">
|
||||
<svg className="w-6 h-6 text-amber-600 flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<div className="flex-1">
|
||||
<h4 className="text-sm font-semibold text-amber-800">Hierarchie-Warnung: Massnahmen vom Typ "Information"</h4>
|
||||
<p className="text-sm text-amber-700 mt-1">
|
||||
Hinweismassnahmen (Stufe 3) duerfen <strong>nicht als Primaermassnahme</strong> akzeptiert werden, wenn konstruktive
|
||||
(Stufe 1) oder technische (Stufe 2) Massnahmen moeglich und zumutbar sind. Pruefen Sie, ob hoeherwertige
|
||||
Massnahmen ergaenzt werden koennen.
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={onDismiss} className="text-amber-400 hover:text-amber-600 transition-colors">
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MeasuresLibraryModal({
|
||||
measures,
|
||||
onSelect,
|
||||
onClose,
|
||||
filterType,
|
||||
}: {
|
||||
measures: ProtectiveMeasure[]
|
||||
onSelect: (measure: ProtectiveMeasure) => void
|
||||
onClose: () => void
|
||||
filterType?: string
|
||||
}) {
|
||||
const [search, setSearch] = useState('')
|
||||
const [selectedSubType, setSelectedSubType] = useState('')
|
||||
|
||||
const filtered = measures.filter((m) => {
|
||||
if (filterType && m.reduction_type !== filterType) return false
|
||||
if (selectedSubType && m.sub_type !== selectedSubType) return false
|
||||
if (search) {
|
||||
const q = search.toLowerCase()
|
||||
return m.name.toLowerCase().includes(q) || m.description.toLowerCase().includes(q)
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
const subTypes = [...new Set(measures.filter((m) => !filterType || m.reduction_type === filterType).map((m) => m.sub_type))].filter(Boolean)
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl w-full max-w-3xl max-h-[80vh] flex flex-col">
|
||||
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Massnahmen-Bibliothek</h3>
|
||||
<button onClick={onClose} className="p-1 text-gray-400 hover:text-gray-600 rounded transition-colors">
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Massnahme suchen..."
|
||||
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
/>
|
||||
{subTypes.length > 1 && (
|
||||
<select
|
||||
value={selectedSubType}
|
||||
onChange={(e) => setSelectedSubType(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white text-sm"
|
||||
>
|
||||
<option value="">Alle Sub-Typen</option>
|
||||
{subTypes.map((st) => (
|
||||
<option key={st} value={st}>{st}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-gray-500">{filtered.length} Massnahmen</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-3">
|
||||
{filtered.map((m) => (
|
||||
<div
|
||||
key={m.id}
|
||||
className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:border-purple-300 hover:bg-purple-50/30 transition-colors cursor-pointer"
|
||||
onClick={() => onSelect(m)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs font-mono text-gray-400">{m.id}</span>
|
||||
{m.sub_type && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-gray-100 text-gray-600">{m.sub_type}</span>
|
||||
)}
|
||||
</div>
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-white">{m.name}</h4>
|
||||
<p className="text-xs text-gray-500 mt-1">{m.description}</p>
|
||||
{m.examples && m.examples.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{m.examples.map((ex, i) => (
|
||||
<span key={i} className="text-xs px-1.5 py-0.5 rounded bg-purple-50 text-purple-600">
|
||||
{ex}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button className="ml-3 px-3 py-1.5 text-xs bg-purple-100 text-purple-700 rounded-lg hover:bg-purple-200 transition-colors flex-shrink-0">
|
||||
Uebernehmen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-500">Keine Massnahmen gefunden</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Suggest Measures Modal (Phase 5)
|
||||
// ============================================================================
|
||||
|
||||
function SuggestMeasuresModal({
|
||||
hazards,
|
||||
projectId,
|
||||
onAddMeasure,
|
||||
onClose,
|
||||
}: {
|
||||
hazards: Hazard[]
|
||||
projectId: string
|
||||
onAddMeasure: (title: string, description: string, reductionType: string, hazardId: string) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [selectedHazard, setSelectedHazard] = useState<string>('')
|
||||
const [suggested, setSuggested] = useState<SuggestedMeasure[]>([])
|
||||
const [loadingSuggestions, setLoadingSuggestions] = useState(false)
|
||||
|
||||
const riskColors: Record<string, string> = {
|
||||
not_acceptable: 'border-red-400 bg-red-50',
|
||||
very_high: 'border-red-300 bg-red-50',
|
||||
critical: 'border-red-300 bg-red-50',
|
||||
high: 'border-orange-300 bg-orange-50',
|
||||
medium: 'border-yellow-300 bg-yellow-50',
|
||||
low: 'border-green-300 bg-green-50',
|
||||
}
|
||||
|
||||
async function handleSelectHazard(hazardId: string) {
|
||||
setSelectedHazard(hazardId)
|
||||
setSuggested([])
|
||||
if (!hazardId) return
|
||||
|
||||
setLoadingSuggestions(true)
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/hazards/${hazardId}/suggest-measures`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
if (res.ok) {
|
||||
const json = await res.json()
|
||||
setSuggested(json.suggested_measures || [])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to suggest measures:', err)
|
||||
} finally {
|
||||
setLoadingSuggestions(false)
|
||||
}
|
||||
}
|
||||
|
||||
const groupedByType = {
|
||||
design: suggested.filter(m => m.reduction_type === 'design'),
|
||||
protection: suggested.filter(m => m.reduction_type === 'protection'),
|
||||
information: suggested.filter(m => m.reduction_type === 'information'),
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl w-full max-w-3xl max-h-[85vh] flex flex-col">
|
||||
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Massnahmen-Vorschlaege</h3>
|
||||
<button onClick={onClose} className="p-1 text-gray-400 hover:text-gray-600 rounded">
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mb-3">
|
||||
Waehlen Sie eine Gefaehrdung, um passende Massnahmen vorgeschlagen zu bekommen.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{hazards.map(h => (
|
||||
<button
|
||||
key={h.id}
|
||||
onClick={() => handleSelectHazard(h.id)}
|
||||
className={`px-3 py-1.5 text-xs rounded-lg border transition-colors ${
|
||||
selectedHazard === h.id
|
||||
? 'border-purple-400 bg-purple-50 text-purple-700 font-medium'
|
||||
: `${riskColors[h.risk_level] || 'border-gray-200 bg-white'} text-gray-700 hover:border-purple-300`
|
||||
}`}
|
||||
>
|
||||
{h.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
{loadingSuggestions ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
|
||||
</div>
|
||||
) : suggested.length > 0 ? (
|
||||
<div className="space-y-6">
|
||||
{(['design', 'protection', 'information'] as const).map(type => {
|
||||
const items = groupedByType[type]
|
||||
if (items.length === 0) return null
|
||||
const config = REDUCTION_TYPES[type]
|
||||
return (
|
||||
<div key={type}>
|
||||
<div className={`flex items-center gap-2 px-3 py-2 rounded-lg ${config.headerColor} mb-3`}>
|
||||
{config.icon}
|
||||
<span className="text-sm font-semibold">{config.label}</span>
|
||||
<span className="ml-auto text-sm font-bold">{items.length}</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{items.map(m => (
|
||||
<div key={m.id} className="border border-gray-200 rounded-lg p-3 hover:bg-gray-50 transition-colors">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs font-mono text-gray-400">{m.id}</span>
|
||||
{m.sub_type && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-gray-100 text-gray-600">{m.sub_type}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">{m.name}</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">{m.description}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onAddMeasure(m.name, m.description, m.reduction_type, selectedHazard)}
|
||||
className="ml-3 px-3 py-1.5 text-xs bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors flex-shrink-0"
|
||||
>
|
||||
Uebernehmen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : selectedHazard ? (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
Keine Vorschlaege fuer diese Gefaehrdung gefunden.
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
Waehlen Sie eine Gefaehrdung aus, um Vorschlaege zu erhalten.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface MitigationFormData {
|
||||
title: string
|
||||
description: string
|
||||
reduction_type: 'design' | 'protection' | 'information'
|
||||
linked_hazard_ids: string[]
|
||||
}
|
||||
|
||||
function MitigationForm({
|
||||
onSubmit,
|
||||
onCancel,
|
||||
hazards,
|
||||
preselectedType,
|
||||
onOpenLibrary,
|
||||
}: {
|
||||
onSubmit: (data: MitigationFormData) => void
|
||||
onCancel: () => void
|
||||
hazards: Hazard[]
|
||||
preselectedType?: 'design' | 'protection' | 'information'
|
||||
onOpenLibrary: (type?: string) => void
|
||||
}) {
|
||||
const [formData, setFormData] = useState<MitigationFormData>({
|
||||
title: '',
|
||||
description: '',
|
||||
reduction_type: preselectedType || 'design',
|
||||
linked_hazard_ids: [],
|
||||
})
|
||||
|
||||
function toggleHazard(id: string) {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
linked_hazard_ids: prev.linked_hazard_ids.includes(id)
|
||||
? prev.linked_hazard_ids.filter((h) => h !== id)
|
||||
: [...prev.linked_hazard_ids, id],
|
||||
}))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Neue Massnahme</h3>
|
||||
<button
|
||||
onClick={() => onOpenLibrary(formData.reduction_type)}
|
||||
className="text-sm px-3 py-1.5 bg-purple-50 text-purple-700 border border-purple-200 rounded-lg hover:bg-purple-100 transition-colors"
|
||||
>
|
||||
Aus Bibliothek waehlen
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Titel *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||
placeholder="z.B. Lichtvorhang an Gefahrenstelle"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Reduktionstyp</label>
|
||||
<select
|
||||
value={formData.reduction_type}
|
||||
onChange={(e) => setFormData({ ...formData, reduction_type: e.target.value as MitigationFormData['reduction_type'] })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
>
|
||||
<option value="design">Stufe 1: Design - Inhaerent sichere Konstruktion</option>
|
||||
<option value="protection">Stufe 2: Schutz - Technische Schutzmassnahmen</option>
|
||||
<option value="information">Stufe 3: Information - Hinweise und Schulungen</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Beschreibung</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
rows={2}
|
||||
placeholder="Detaillierte Beschreibung der Massnahme..."
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
{hazards.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Verknuepfte Gefaehrdungen</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{hazards.map((h) => (
|
||||
<button
|
||||
key={h.id}
|
||||
onClick={() => toggleHazard(h.id)}
|
||||
className={`px-3 py-1.5 text-xs rounded-lg border transition-colors ${
|
||||
formData.linked_hazard_ids.includes(h.id)
|
||||
? 'border-purple-400 bg-purple-50 text-purple-700'
|
||||
: 'border-gray-200 bg-white text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{h.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4 flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => onSubmit(formData)}
|
||||
disabled={!formData.title}
|
||||
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
|
||||
formData.title
|
||||
? 'bg-purple-600 text-white hover:bg-purple-700'
|
||||
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
Hinzufuegen
|
||||
</button>
|
||||
<button onClick={onCancel} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MitigationCard({
|
||||
mitigation,
|
||||
onVerify,
|
||||
onDelete,
|
||||
}: {
|
||||
mitigation: Mitigation
|
||||
onVerify: (id: string) => void
|
||||
onDelete: (id: string) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-white">{mitigation.title}</h4>
|
||||
{mitigation.title.startsWith('Auto:') && (
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-green-100 text-green-700">
|
||||
Auto
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<StatusBadge status={mitigation.status} />
|
||||
</div>
|
||||
{mitigation.description && (
|
||||
<p className="text-xs text-gray-500 mb-3">{mitigation.description}</p>
|
||||
)}
|
||||
{mitigation.linked_hazard_names.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{mitigation.linked_hazard_names.map((name, i) => (
|
||||
<span key={i} className="inline-flex items-center px-1.5 py-0.5 rounded text-xs bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400">
|
||||
{name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{mitigation.status !== 'verified' && (
|
||||
<button
|
||||
onClick={() => onVerify(mitigation.id)}
|
||||
className="text-xs px-2.5 py-1 bg-green-50 text-green-700 border border-green-200 rounded-lg hover:bg-green-100 transition-colors"
|
||||
>
|
||||
Verifizieren
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onDelete(mitigation.id)}
|
||||
className="text-xs px-2.5 py-1 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||
>
|
||||
Loeschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import { REDUCTION_TYPES } from './_components/types'
|
||||
import { HierarchyWarning } from './_components/HierarchyWarning'
|
||||
import { MeasuresLibraryModal } from './_components/MeasuresLibraryModal'
|
||||
import { SuggestMeasuresModal } from './_components/SuggestMeasuresModal'
|
||||
import { MitigationForm } from './_components/MitigationForm'
|
||||
import { MitigationCard } from './_components/MitigationCard'
|
||||
import { ProtectiveMeasure } from './_components/types'
|
||||
import { useMitigations } from './_hooks/useMitigations'
|
||||
|
||||
export default function MitigationsPage() {
|
||||
const params = useParams()
|
||||
const projectId = params.projectId as string
|
||||
const [mitigations, setMitigations] = useState<Mitigation[]>([])
|
||||
const [hazards, setHazards] = useState<Hazard[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
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 [hierarchyWarning, setHierarchyWarning] = useState<boolean>(false)
|
||||
const [showLibrary, setShowLibrary] = useState(false)
|
||||
const [libraryFilter, setLibraryFilter] = useState<string | undefined>()
|
||||
const [measures, setMeasures] = useState<ProtectiveMeasure[]>([])
|
||||
// Phase 5: Suggest measures
|
||||
const [showSuggest, setShowSuggest] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [projectId])
|
||||
|
||||
async function fetchData() {
|
||||
try {
|
||||
const [mitRes, hazRes] = await Promise.all([
|
||||
fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations`),
|
||||
fetch(`/api/sdk/v1/iace/projects/${projectId}/hazards`),
|
||||
])
|
||||
if (mitRes.ok) {
|
||||
const json = await mitRes.json()
|
||||
const mits = json.mitigations || json || []
|
||||
setMitigations(mits)
|
||||
// Check hierarchy: if information-only measures exist without design/protection
|
||||
validateHierarchy(mits)
|
||||
}
|
||||
if (hazRes.ok) {
|
||||
const json = await hazRes.json()
|
||||
setHazards((json.hazards || json || []).map((h: Hazard) => ({ id: h.id, name: h.name, risk_level: h.risk_level, category: h.category })))
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch data:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function validateHierarchy(mits: Mitigation[]) {
|
||||
if (mits.length === 0) return
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/validate-mitigation-hierarchy`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
mitigations: mits.map((m) => ({
|
||||
reduction_type: m.reduction_type,
|
||||
linked_hazard_ids: m.linked_hazard_ids,
|
||||
})),
|
||||
}),
|
||||
})
|
||||
if (res.ok) {
|
||||
const json = await res.json()
|
||||
setHierarchyWarning(json.has_warning === true)
|
||||
}
|
||||
} catch {
|
||||
// Non-critical, ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchMeasuresLibrary(type?: string) {
|
||||
try {
|
||||
const url = type
|
||||
? `/api/sdk/v1/iace/protective-measures-library?reduction_type=${type}`
|
||||
: '/api/sdk/v1/iace/protective-measures-library'
|
||||
const res = await fetch(url)
|
||||
if (res.ok) {
|
||||
const json = await res.json()
|
||||
setMeasures(json.protective_measures || [])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch measures library:', err)
|
||||
}
|
||||
}
|
||||
|
||||
function handleOpenLibrary(type?: string) {
|
||||
setLibraryFilter(type)
|
||||
fetchMeasuresLibrary(type)
|
||||
@@ -672,80 +39,11 @@ export default function MitigationsPage() {
|
||||
setPreselectedType(measure.reduction_type as 'design' | 'protection' | 'information')
|
||||
}
|
||||
|
||||
async function handleSubmit(data: MitigationFormData) {
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
if (res.ok) {
|
||||
setShowForm(false)
|
||||
setPreselectedType(undefined)
|
||||
await fetchData()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to add mitigation:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddSuggestedMeasure(title: string, description: string, reductionType: string, hazardId: string) {
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
description,
|
||||
reduction_type: reductionType,
|
||||
linked_hazard_ids: [hazardId],
|
||||
}),
|
||||
})
|
||||
if (res.ok) {
|
||||
await fetchData()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to add suggested measure:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleVerify(id: string) {
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations/${id}/verify`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
if (res.ok) {
|
||||
await fetchData()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to verify mitigation:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
if (!confirm('Massnahme wirklich loeschen?')) return
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations/${id}`, { method: 'DELETE' })
|
||||
if (res.ok) {
|
||||
await fetchData()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to delete mitigation:', err)
|
||||
}
|
||||
}
|
||||
|
||||
function handleAddForType(type: 'design' | 'protection' | 'information') {
|
||||
setPreselectedType(type)
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
const byType = {
|
||||
design: mitigations.filter((m) => m.reduction_type === 'design'),
|
||||
protection: mitigations.filter((m) => m.reduction_type === 'protection'),
|
||||
information: mitigations.filter((m) => m.reduction_type === 'information'),
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
@@ -786,10 +84,7 @@ export default function MitigationsPage() {
|
||||
Bibliothek
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setPreselectedType(undefined)
|
||||
setShowForm(true)
|
||||
}}
|
||||
onClick={() => { setPreselectedType(undefined); 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">
|
||||
@@ -800,26 +95,21 @@ export default function MitigationsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hierarchy Warning */}
|
||||
{hierarchyWarning && (
|
||||
<HierarchyWarning onDismiss={() => setHierarchyWarning(false)} />
|
||||
)}
|
||||
{hierarchyWarning && <HierarchyWarning onDismiss={() => setHierarchyWarning(false)} />}
|
||||
|
||||
{/* Form */}
|
||||
{showForm && (
|
||||
<MitigationForm
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={() => {
|
||||
setShowForm(false)
|
||||
setPreselectedType(undefined)
|
||||
onSubmit={async (data) => {
|
||||
const ok = await handleSubmit(data)
|
||||
if (ok) { setShowForm(false); setPreselectedType(undefined) }
|
||||
}}
|
||||
onCancel={() => { setShowForm(false); setPreselectedType(undefined) }}
|
||||
hazards={hazards}
|
||||
preselectedType={preselectedType}
|
||||
onOpenLibrary={handleOpenLibrary}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Measures Library Modal */}
|
||||
{showLibrary && (
|
||||
<MeasuresLibraryModal
|
||||
measures={measures}
|
||||
@@ -829,7 +119,6 @@ export default function MitigationsPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Suggest Measures Modal (Phase 5) */}
|
||||
{showSuggest && (
|
||||
<SuggestMeasuresModal
|
||||
hazards={hazards}
|
||||
@@ -854,8 +143,6 @@ export default function MitigationsPage() {
|
||||
</div>
|
||||
<span className="ml-auto text-sm font-bold">{items.length}</span>
|
||||
</div>
|
||||
|
||||
{/* Sub-types overview */}
|
||||
<div className="mb-3 flex flex-wrap gap-1">
|
||||
{config.subTypes.map((st) => (
|
||||
<span key={st.value} className="text-xs px-1.5 py-0.5 rounded bg-white/60 text-gray-500 border border-gray-200/50">
|
||||
@@ -863,18 +150,11 @@ export default function MitigationsPage() {
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{items.map((m) => (
|
||||
<MitigationCard
|
||||
key={m.id}
|
||||
mitigation={m}
|
||||
onVerify={handleVerify}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
<MitigationCard key={m.id} mitigation={m} onVerify={handleVerify} onDelete={handleDelete} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex gap-2">
|
||||
<button
|
||||
onClick={() => handleAddForType(type)}
|
||||
|
||||
Reference in New Issue
Block a user