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 React, { useState } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
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
|
// Transition Error Banner
|
||||||
// =============================================================================
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
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
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
function TransitionErrorBanner({
|
function TransitionErrorBanner({
|
||||||
controlId,
|
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" />
|
<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>
|
</svg>
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-medium text-orange-800">
|
<h4 className="font-medium text-orange-800">Status-Transition blockiert ({controlId})</h4>
|
||||||
Status-Transition blockiert ({controlId})
|
|
||||||
</h4>
|
|
||||||
<ul className="mt-2 space-y-1">
|
<ul className="mt-2 space-y-1">
|
||||||
{violations.map((v, i) => (
|
{violations.map((v, i) => (
|
||||||
<li key={i} className="text-sm text-orange-700 flex items-start gap-2">
|
<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() {
|
export default function ControlsPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [filter, setFilter] = useState<string>('all')
|
const [filter, setFilter] = useState<string>('all')
|
||||||
const [showAddForm, setShowAddForm] = useState(false)
|
const [showAddForm, setShowAddForm] = useState(false)
|
||||||
|
const [transitionError, setTransitionError] = useState<{ controlId: string; violations: string[] } | null>(null)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
state,
|
state, dispatch, loading, error, setError,
|
||||||
loading,
|
effectivenessMap, evidenceMap,
|
||||||
error,
|
handleStatusChange: _handleStatusChange,
|
||||||
setError,
|
|
||||||
effectivenessMap,
|
|
||||||
evidenceMap,
|
|
||||||
handleStatusChange,
|
|
||||||
handleEffectivenessChange,
|
handleEffectivenessChange,
|
||||||
handleAddControl,
|
handleAddControl,
|
||||||
} = useControlsData()
|
} = useControlsData()
|
||||||
|
|
||||||
// Transition error from Anti-Fake-Evidence state machine (409 Conflict)
|
const {
|
||||||
const [transitionError, setTransitionError] = useState<{ controlId: string; violations: string[] } | null>(null)
|
ragLoading, ragSuggestions, showRagPanel, setShowRagPanel,
|
||||||
|
selectedRequirementId, setSelectedRequirementId,
|
||||||
|
suggestControlsFromRAG, addSuggestedControl,
|
||||||
|
} = useRAGSuggestions(setError)
|
||||||
|
|
||||||
// Track effectiveness locally as it's not in the SDK state type
|
// Wrap status change to capture 409 transition errors
|
||||||
const [effectivenessMap, setEffectivenessMap] = useState<Record<string, number>>({})
|
const handleStatusChange = async (controlId: string, newStatus: import('@/lib/sdk').ImplementationStatus) => {
|
||||||
// Track linked evidence per control
|
const oldControl = state.controls.find(c => c.id === controlId)
|
||||||
const [evidenceMap, setEvidenceMap] = useState<Record<string, { id: string; title: string; status: string }[]>>({})
|
const oldStatus = oldControl?.implementationStatus
|
||||||
|
|
||||||
|
dispatch({ type: 'UPDATE_CONTROL', payload: { id: controlId, data: { implementationStatus: newStatus } } })
|
||||||
|
|
||||||
const fetchEvidenceForControls = async (controlIds: string[]) => {
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/sdk/v1/compliance/evidence')
|
const res = await fetch(`/api/sdk/v1/compliance/controls/${controlId}`, {
|
||||||
if (res.ok) {
|
method: 'PUT',
|
||||||
const data = await res.json()
|
headers: { 'Content-Type': 'application/json' },
|
||||||
const allEvidence = data.evidence || data
|
body: JSON.stringify({ implementation_status: newStatus }),
|
||||||
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,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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 {
|
} 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
|
// Build display controls
|
||||||
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
|
|
||||||
const displayControls: DisplayControl[] = state.controls.map(ctrl => {
|
const displayControls: DisplayControl[] = state.controls.map(ctrl => {
|
||||||
const effectivenessPercent = effectivenessMap[ctrl.id] ??
|
const effectivenessPercent = effectivenessMap[ctrl.id] ??
|
||||||
(ctrl.implementationStatus === 'IMPLEMENTED' ? 85 :
|
(ctrl.implementationStatus === 'IMPLEMENTED' ? 85 : ctrl.implementationStatus === 'PARTIAL' ? 50 : 0)
|
||||||
ctrl.implementationStatus === 'PARTIAL' ? 50 : 0)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: ctrl.id,
|
id: ctrl.id,
|
||||||
name: ctrl.name,
|
name: ctrl.name,
|
||||||
@@ -519,7 +129,7 @@ export default function ControlsPage() {
|
|||||||
owner: ctrl.owner,
|
owner: ctrl.owner,
|
||||||
dueDate: ctrl.dueDate,
|
dueDate: ctrl.dueDate,
|
||||||
code: ctrl.id,
|
code: ctrl.id,
|
||||||
displayType: 'preventive' as DisplayControlType,
|
displayType: 'preventive' as import('./_types').DisplayControlType,
|
||||||
displayCategory: mapControlTypeToDisplay(ctrl.type),
|
displayCategory: mapControlTypeToDisplay(ctrl.type),
|
||||||
displayStatus: mapStatusToDisplay(ctrl.implementationStatus),
|
displayStatus: mapStatusToDisplay(ctrl.implementationStatus),
|
||||||
effectivenessPercent,
|
effectivenessPercent,
|
||||||
@@ -531,160 +141,18 @@ export default function ControlsPage() {
|
|||||||
|
|
||||||
const filteredControls = filter === 'all'
|
const filteredControls = filter === 'all'
|
||||||
? displayControls
|
? displayControls
|
||||||
: displayControls.filter(c =>
|
: displayControls.filter(c => c.displayStatus === filter || c.displayType === filter || c.displayCategory === filter)
|
||||||
c.displayStatus === filter ||
|
|
||||||
c.displayType === filter ||
|
|
||||||
c.displayCategory === filter
|
|
||||||
)
|
|
||||||
|
|
||||||
const implementedCount = displayControls.filter(c => c.displayStatus === 'implemented').length
|
const implementedCount = displayControls.filter(c => c.displayStatus === 'implemented').length
|
||||||
const avgEffectiveness = displayControls.length > 0
|
const avgEffectiveness = displayControls.length > 0
|
||||||
? Math.round(displayControls.reduce((sum, c) => sum + c.effectivenessPercent, 0) / displayControls.length)
|
? Math.round(displayControls.reduce((sum, c) => sum + c.effectivenessPercent, 0) / displayControls.length)
|
||||||
: 0
|
: 0
|
||||||
const partialCount = displayControls.filter(c => c.displayStatus === 'partial').length
|
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']
|
const stepInfo = STEP_EXPLANATIONS['controls']
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<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">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowRagPanel(!showRagPanel)}
|
onClick={() => setShowRagPanel(!showRagPanel)}
|
||||||
@@ -707,7 +175,6 @@ export default function ControlsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</StepHeader>
|
</StepHeader>
|
||||||
|
|
||||||
{/* Add Form */}
|
|
||||||
{showAddForm && (
|
{showAddForm && (
|
||||||
<AddControlForm
|
<AddControlForm
|
||||||
onSubmit={(data) => { handleAddControl(data); setShowAddForm(false) }}
|
onSubmit={(data) => { handleAddControl(data); setShowAddForm(false) }}
|
||||||
@@ -715,7 +182,6 @@ export default function ControlsPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* RAG Controls Panel */}
|
|
||||||
{showRagPanel && (
|
{showRagPanel && (
|
||||||
<RAGPanel
|
<RAGPanel
|
||||||
selectedRequirementId={selectedRequirementId}
|
selectedRequirementId={selectedRequirementId}
|
||||||
@@ -729,7 +195,6 @@ export default function ControlsPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Error Banner */}
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center justify-between">
|
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center justify-between">
|
||||||
<span>{error}</span>
|
<span>{error}</span>
|
||||||
@@ -737,7 +202,6 @@ export default function ControlsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Transition Error Banner (Anti-Fake-Evidence 409 violations) */}
|
|
||||||
{transitionError && (
|
{transitionError && (
|
||||||
<TransitionErrorBanner
|
<TransitionErrorBanner
|
||||||
controlId={transitionError.controlId}
|
controlId={transitionError.controlId}
|
||||||
@@ -746,7 +210,6 @@ export default function ControlsPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Requirements Alert */}
|
|
||||||
{state.requirements.length === 0 && !loading && (
|
{state.requirements.length === 0 && !loading && (
|
||||||
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4">
|
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
@@ -763,19 +226,12 @@ export default function ControlsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<StatsCards
|
<StatsCards total={displayControls.length} implementedCount={implementedCount} avgEffectiveness={avgEffectiveness} partialCount={partialCount} />
|
||||||
total={displayControls.length}
|
|
||||||
implementedCount={implementedCount}
|
|
||||||
avgEffectiveness={avgEffectiveness}
|
|
||||||
partialCount={partialCount}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FilterBar filter={filter} onFilterChange={setFilter} />
|
<FilterBar filter={filter} onFilterChange={setFilter} />
|
||||||
|
|
||||||
{/* Loading State */}
|
|
||||||
{loading && <LoadingSkeleton />}
|
{loading && <LoadingSkeleton />}
|
||||||
|
|
||||||
{/* Controls List */}
|
|
||||||
{!loading && (
|
{!loading && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{filteredControls.map(control => (
|
{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'
|
'use client'
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react'
|
import { useState } from 'react'
|
||||||
import { useParams } from 'next/navigation'
|
import { useParams } from 'next/navigation'
|
||||||
|
import { Component } from './_components/types'
|
||||||
interface Component {
|
import { ComponentTreeNode } from './_components/ComponentTreeNode'
|
||||||
id: string
|
import { ComponentForm } from './_components/ComponentForm'
|
||||||
name: string
|
import { ComponentLibraryModal } from './_components/ComponentLibraryModal'
|
||||||
type: string
|
import { useComponents } from './_hooks/useComponents'
|
||||||
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
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export default function ComponentsPage() {
|
export default function ComponentsPage() {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const projectId = params.projectId as string
|
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 [showForm, setShowForm] = useState(false)
|
||||||
const [editingComponent, setEditingComponent] = useState<Component | null>(null)
|
const [editingComponent, setEditingComponent] = useState<Component | null>(null)
|
||||||
const [addingParentId, setAddingParentId] = useState<string | null>(null)
|
const [addingParentId, setAddingParentId] = useState<string | null>(null)
|
||||||
const [showLibrary, setShowLibrary] = useState(false)
|
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) {
|
function handleEdit(component: Component) {
|
||||||
setEditingComponent(component)
|
setEditingComponent(component)
|
||||||
setAddingParentId(null)
|
setAddingParentId(null)
|
||||||
@@ -694,33 +31,11 @@ export default function ComponentsPage() {
|
|||||||
setShowForm(true)
|
setShowForm(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleAddFromLibrary(libraryComps: LibraryComponent[], energySrcs: EnergySource[]) {
|
function resetForm() {
|
||||||
setShowLibrary(false)
|
setShowForm(false)
|
||||||
const energySourceIds = energySrcs.map(e => e.id)
|
setEditingComponent(null)
|
||||||
|
setAddingParentId(null)
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
const tree = buildTree(components)
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -752,11 +67,7 @@ export default function ComponentsPage() {
|
|||||||
Aus Bibliothek waehlen
|
Aus Bibliothek waehlen
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => { setShowForm(true); setEditingComponent(null); setAddingParentId(null) }}
|
||||||
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"
|
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">
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -768,23 +79,20 @@ export default function ComponentsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Library Modal */}
|
|
||||||
{showLibrary && (
|
{showLibrary && (
|
||||||
<ComponentLibraryModal
|
<ComponentLibraryModal
|
||||||
onAdd={handleAddFromLibrary}
|
onAdd={async (comps, energy) => { setShowLibrary(false); await handleAddFromLibrary(comps, energy) }}
|
||||||
onClose={() => setShowLibrary(false)}
|
onClose={() => setShowLibrary(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Form */}
|
|
||||||
{showForm && (
|
{showForm && (
|
||||||
<ComponentForm
|
<ComponentForm
|
||||||
onSubmit={handleSubmit}
|
onSubmit={async (data) => {
|
||||||
onCancel={() => {
|
const ok = await handleSubmit(data, editingComponent?.id)
|
||||||
setShowForm(false)
|
if (ok) resetForm()
|
||||||
setEditingComponent(null)
|
|
||||||
setAddingParentId(null)
|
|
||||||
}}
|
}}
|
||||||
|
onCancel={resetForm}
|
||||||
initialData={editingComponent}
|
initialData={editingComponent}
|
||||||
parentId={addingParentId}
|
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'
|
'use client'
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react'
|
import { useState } from 'react'
|
||||||
import { useParams } from 'next/navigation'
|
import { useParams } from 'next/navigation'
|
||||||
|
import { REDUCTION_TYPES } from './_components/types'
|
||||||
interface Mitigation {
|
import { HierarchyWarning } from './_components/HierarchyWarning'
|
||||||
id: string
|
import { MeasuresLibraryModal } from './_components/MeasuresLibraryModal'
|
||||||
title: string
|
import { SuggestMeasuresModal } from './_components/SuggestMeasuresModal'
|
||||||
description: string
|
import { MitigationForm } from './_components/MitigationForm'
|
||||||
reduction_type: 'design' | 'protection' | 'information'
|
import { MitigationCard } from './_components/MitigationCard'
|
||||||
status: 'planned' | 'implemented' | 'verified'
|
import { ProtectiveMeasure } from './_components/types'
|
||||||
linked_hazard_ids: string[]
|
import { useMitigations } from './_hooks/useMitigations'
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function MitigationsPage() {
|
export default function MitigationsPage() {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const projectId = params.projectId as string
|
const projectId = params.projectId as string
|
||||||
const [mitigations, setMitigations] = useState<Mitigation[]>([])
|
|
||||||
const [hazards, setHazards] = useState<Hazard[]>([])
|
const {
|
||||||
const [loading, setLoading] = useState(true)
|
hazards, loading, hierarchyWarning, setHierarchyWarning,
|
||||||
|
measures, byType,
|
||||||
|
fetchMeasuresLibrary, handleSubmit, handleAddSuggestedMeasure, handleVerify, handleDelete,
|
||||||
|
} = useMitigations(projectId)
|
||||||
|
|
||||||
const [showForm, setShowForm] = useState(false)
|
const [showForm, setShowForm] = useState(false)
|
||||||
const [preselectedType, setPreselectedType] = useState<'design' | 'protection' | 'information' | undefined>()
|
const [preselectedType, setPreselectedType] = useState<'design' | 'protection' | 'information' | undefined>()
|
||||||
const [hierarchyWarning, setHierarchyWarning] = useState<boolean>(false)
|
|
||||||
const [showLibrary, setShowLibrary] = useState(false)
|
const [showLibrary, setShowLibrary] = useState(false)
|
||||||
const [libraryFilter, setLibraryFilter] = useState<string | undefined>()
|
const [libraryFilter, setLibraryFilter] = useState<string | undefined>()
|
||||||
const [measures, setMeasures] = useState<ProtectiveMeasure[]>([])
|
|
||||||
// Phase 5: Suggest measures
|
|
||||||
const [showSuggest, setShowSuggest] = useState(false)
|
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) {
|
function handleOpenLibrary(type?: string) {
|
||||||
setLibraryFilter(type)
|
setLibraryFilter(type)
|
||||||
fetchMeasuresLibrary(type)
|
fetchMeasuresLibrary(type)
|
||||||
@@ -672,80 +39,11 @@ export default function MitigationsPage() {
|
|||||||
setPreselectedType(measure.reduction_type as 'design' | 'protection' | 'information')
|
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') {
|
function handleAddForType(type: 'design' | 'protection' | 'information') {
|
||||||
setPreselectedType(type)
|
setPreselectedType(type)
|
||||||
setShowForm(true)
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-64">
|
<div className="flex items-center justify-center h-64">
|
||||||
@@ -786,10 +84,7 @@ export default function MitigationsPage() {
|
|||||||
Bibliothek
|
Bibliothek
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => { setPreselectedType(undefined); setShowForm(true) }}
|
||||||
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"
|
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">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Hierarchy Warning */}
|
{hierarchyWarning && <HierarchyWarning onDismiss={() => setHierarchyWarning(false)} />}
|
||||||
{hierarchyWarning && (
|
|
||||||
<HierarchyWarning onDismiss={() => setHierarchyWarning(false)} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Form */}
|
|
||||||
{showForm && (
|
{showForm && (
|
||||||
<MitigationForm
|
<MitigationForm
|
||||||
onSubmit={handleSubmit}
|
onSubmit={async (data) => {
|
||||||
onCancel={() => {
|
const ok = await handleSubmit(data)
|
||||||
setShowForm(false)
|
if (ok) { setShowForm(false); setPreselectedType(undefined) }
|
||||||
setPreselectedType(undefined)
|
|
||||||
}}
|
}}
|
||||||
|
onCancel={() => { setShowForm(false); setPreselectedType(undefined) }}
|
||||||
hazards={hazards}
|
hazards={hazards}
|
||||||
preselectedType={preselectedType}
|
preselectedType={preselectedType}
|
||||||
onOpenLibrary={handleOpenLibrary}
|
onOpenLibrary={handleOpenLibrary}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Measures Library Modal */}
|
|
||||||
{showLibrary && (
|
{showLibrary && (
|
||||||
<MeasuresLibraryModal
|
<MeasuresLibraryModal
|
||||||
measures={measures}
|
measures={measures}
|
||||||
@@ -829,7 +119,6 @@ export default function MitigationsPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Suggest Measures Modal (Phase 5) */}
|
|
||||||
{showSuggest && (
|
{showSuggest && (
|
||||||
<SuggestMeasuresModal
|
<SuggestMeasuresModal
|
||||||
hazards={hazards}
|
hazards={hazards}
|
||||||
@@ -854,8 +143,6 @@ export default function MitigationsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<span className="ml-auto text-sm font-bold">{items.length}</span>
|
<span className="ml-auto text-sm font-bold">{items.length}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sub-types overview */}
|
|
||||||
<div className="mb-3 flex flex-wrap gap-1">
|
<div className="mb-3 flex flex-wrap gap-1">
|
||||||
{config.subTypes.map((st) => (
|
{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">
|
<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>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{items.map((m) => (
|
{items.map((m) => (
|
||||||
<MitigationCard
|
<MitigationCard key={m.id} mitigation={m} onVerify={handleVerify} onDelete={handleDelete} />
|
||||||
key={m.id}
|
|
||||||
mitigation={m}
|
|
||||||
onVerify={handleVerify}
|
|
||||||
onDelete={handleDelete}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 flex gap-2">
|
<div className="mt-3 flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleAddForType(type)}
|
onClick={() => handleAddForType(type)}
|
||||||
|
|||||||
Reference in New Issue
Block a user