Use-Case-Mapping-Filter für Master Controls + Mapper-Präzisionsfix
CI / detect-changes (push) Successful in 14s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / build-sha-integrity (push) Failing after 7s
CI / validate-canonical-controls (push) Successful in 13s
CI / loc-budget (push) Failing after 15s
CI / go-lint (push) Has been skipped
CI / test-go (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Successful in 2m23s
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-backend (push) Successful in 34s
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
CI / detect-changes (push) Successful in 14s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / build-sha-integrity (push) Failing after 7s
CI / validate-canonical-controls (push) Successful in 13s
CI / loc-budget (push) Failing after 15s
CI / go-lint (push) Has been skipped
CI / test-go (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Successful in 2m23s
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-backend (push) Successful in 34s
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
Phase 2: Live-Filter an /sdk/master-controls (Use Case, Quell-Regulierung, Verifikations-Methode, Coverage, Primärzweck-Toggle, category via Member-EXISTS). API mit EXISTS-Filtern + gecachten Meta-Counts in master-controls/route.ts. Phase A: neue UseCase telekommunikation + Fix der Impressum-Fehlrouten im Register (TKG/AT-TKG->telekommunikation, telemedien->dse, GewO->handelsrecht); echte Impressum-Quellen (TMG/Mediengesetz) bleiben impressum. Deterministischer Seed aus source_regulation; Tests grün. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
USE_CASE_LABELS, MC_VERIFICATION_LABELS, useCaseLabel, mcVerificationLabel,
|
||||
} from '../components/mcMappingLabels'
|
||||
|
||||
describe('useCaseLabel', () => {
|
||||
it('maps known use-case keys to German labels', () => {
|
||||
expect(useCaseLabel('impressum')).toBe('Impressum')
|
||||
expect(useCaseLabel('cookie_banner')).toBe('Cookie-Banner')
|
||||
expect(useCaseLabel('code_security')).toBe('Code Security')
|
||||
expect(useCaseLabel('dse')).toBe('Datenschutzerklärung')
|
||||
})
|
||||
|
||||
it('humanizes an unknown key instead of showing the raw slug', () => {
|
||||
expect(useCaseLabel('brand_new_thing')).toBe('Brand New Thing')
|
||||
})
|
||||
})
|
||||
|
||||
describe('mcVerificationLabel', () => {
|
||||
it('maps the master-control verification methods', () => {
|
||||
expect(mcVerificationLabel('source_code')).toBe('Source Code')
|
||||
expect(mcVerificationLabel('it_process')).toBe('IT-Prozess')
|
||||
expect(mcVerificationLabel('network')).toBe('Netzwerk/Infra')
|
||||
expect(mcVerificationLabel('document')).toBe('Dokument')
|
||||
})
|
||||
|
||||
it('humanizes an unknown method', () => {
|
||||
expect(mcVerificationLabel('telepathy')).toBe('Telepathy')
|
||||
})
|
||||
})
|
||||
|
||||
describe('label coverage', () => {
|
||||
it('labels the security/code use cases (>=50% code+process focus)', () => {
|
||||
for (const k of ['code_security', 'network_security', 'cra', 'isms', 'tisax']) {
|
||||
expect(USE_CASE_LABELS[k]).toBeTruthy()
|
||||
}
|
||||
})
|
||||
|
||||
it('covers every master-control verification method', () => {
|
||||
for (const m of ['document', 'source_code', 'network', 'it_process', 'hybrid', 'manual']) {
|
||||
expect(MC_VERIFICATION_LABELS[m]).toBeTruthy()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
VERIFICATION_METHODS, CATEGORY_OPTIONS, EVIDENCE_TYPE_OPTIONS,
|
||||
} from './helpers'
|
||||
import { ControlsMeta } from './useControlLibraryState'
|
||||
import { useCaseLabel, mcVerificationLabel } from './mcMappingLabels'
|
||||
import { GeneratorModal } from './GeneratorModal'
|
||||
|
||||
interface ControlListViewProps {
|
||||
@@ -34,6 +35,10 @@ interface ControlListViewProps {
|
||||
domainFilter: string
|
||||
stateFilter: string
|
||||
verificationFilter: string
|
||||
useCaseFilter: string
|
||||
primaryOnly: boolean
|
||||
regulationFilter: string
|
||||
mappedFilter: string
|
||||
categoryFilter: string
|
||||
evidenceTypeFilter: string
|
||||
audienceFilter: string
|
||||
@@ -46,6 +51,10 @@ interface ControlListViewProps {
|
||||
setDomainFilter: (v: string) => void
|
||||
setStateFilter: (v: string) => void
|
||||
setVerificationFilter: (v: string) => void
|
||||
setUseCaseFilter: (v: string) => void
|
||||
setPrimaryOnly: (v: boolean) => void
|
||||
setRegulationFilter: (v: string) => void
|
||||
setMappedFilter: (v: string) => void
|
||||
setCategoryFilter: (v: string) => void
|
||||
setEvidenceTypeFilter: (v: string) => void
|
||||
setAudienceFilter: (v: string) => void
|
||||
@@ -71,10 +80,12 @@ export function ControlListView({
|
||||
reviewCount, bulkProcessing, showStats, processedStats,
|
||||
showGenerator, currentPage, totalPages, sortBy,
|
||||
searchQuery, severityFilter, domainFilter, stateFilter,
|
||||
verificationFilter, categoryFilter, evidenceTypeFilter, audienceFilter,
|
||||
verificationFilter, useCaseFilter, primaryOnly, regulationFilter, mappedFilter,
|
||||
categoryFilter, evidenceTypeFilter, audienceFilter,
|
||||
sourceFilter, typeFilter, hideDuplicates,
|
||||
setSearchQuery, setSeverityFilter, setDomainFilter, setStateFilter,
|
||||
setVerificationFilter, setCategoryFilter, setEvidenceTypeFilter, setAudienceFilter,
|
||||
setVerificationFilter, setUseCaseFilter, setPrimaryOnly, setRegulationFilter, setMappedFilter,
|
||||
setCategoryFilter, setEvidenceTypeFilter, setAudienceFilter,
|
||||
setSourceFilter, setTypeFilter, setHideDuplicates, setSortBy,
|
||||
setShowStats, setShowGenerator, setCurrentPage,
|
||||
onSelectControl, onCreateMode, onEnterReview, onBulkReject, onRefresh, onLoadStats, onFullReload,
|
||||
@@ -176,18 +187,60 @@ export function ControlListView({
|
||||
className="rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||
Duplikate ausblenden
|
||||
</label>
|
||||
{meta?.use_case_counts && (
|
||||
<select value={useCaseFilter} onChange={e => setUseCaseFilter(e.target.value)}
|
||||
className="text-sm border border-purple-300 bg-purple-50 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-purple-500 max-w-[260px]">
|
||||
<option value="">Use Case (alle)</option>
|
||||
{Object.entries(meta.use_case_counts).sort((a, b) => b[1] - a[1]).map(([k, c]) => (
|
||||
<option key={k} value={k}>{useCaseLabel(k)} ({c})</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{meta?.use_case_counts && useCaseFilter && (
|
||||
<label className="flex items-center gap-1.5 text-xs text-gray-600 cursor-pointer whitespace-nowrap"
|
||||
title="Nur Master Controls, deren Primärzweck dieser Use Case ist (blendet über-geclusterte Mehrfachzwecke aus)">
|
||||
<input type="checkbox" checked={primaryOnly} onChange={e => setPrimaryOnly(e.target.checked)}
|
||||
className="rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||
nur Primärzweck
|
||||
</label>
|
||||
)}
|
||||
{meta?.regulations && meta.regulations.length > 0 && (
|
||||
<select value={regulationFilter} onChange={e => setRegulationFilter(e.target.value)}
|
||||
className="text-sm border border-blue-300 bg-blue-50 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-purple-500 max-w-[260px]">
|
||||
<option value="">Regulierung (alle)</option>
|
||||
{meta.regulations.map(rg => (
|
||||
<option key={rg.source_regulation} value={rg.source_regulation}>{rg.source_regulation} ({rg.count})</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<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>
|
||||
))}
|
||||
{Object.keys(meta?.verification_method_counts || {})
|
||||
.filter(k => k !== '__none__' && !(k in VERIFICATION_METHODS))
|
||||
.map(k => (
|
||||
<option key={k} value={k}>{mcVerificationLabel(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>
|
||||
{meta?.mapped_total != null && (
|
||||
<select value={mappedFilter} onChange={e => setMappedFilter(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="">Coverage: alle</option>
|
||||
<option value="mapped">Zugeordnet ({meta.mapped_total})</option>
|
||||
<option value="unmapped">Offen ({meta.unmapped_count ?? 0})</option>
|
||||
</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>)}
|
||||
{Object.keys(meta?.category_counts || {})
|
||||
.filter(k => k !== '__none__' && !CATEGORY_OPTIONS.some(c => c.value === k))
|
||||
.map(k => <option key={k} value={k}>{k} ({meta!.category_counts![k]})</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)}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
// Display labels for the master-control mapping dimensions (use case +
|
||||
// verification method). Keys mirror the backend use_case_registry; an unknown
|
||||
// key humanizes gracefully so a newly-seeded use case still renders.
|
||||
|
||||
export const USE_CASE_LABELS: Record<string, string> = {
|
||||
impressum: 'Impressum',
|
||||
telekommunikation: 'Telekommunikation (TKG)',
|
||||
dse: 'Datenschutzerklärung',
|
||||
agb: 'AGB',
|
||||
cookie_banner: 'Cookie-Banner',
|
||||
widerruf: 'Widerruf',
|
||||
dsr: 'Betroffenenrechte (DSR)',
|
||||
loeschkonzept: 'Löschkonzept',
|
||||
avv: 'Auftragsverarbeitung (AVV)',
|
||||
dsfa: 'DSFA',
|
||||
code_security: 'Code Security',
|
||||
network_security: 'Network Security',
|
||||
cra: 'Cyber Resilience Act',
|
||||
isms: 'ISMS',
|
||||
tisax: 'TISAX',
|
||||
kritis: 'KRITIS',
|
||||
dora: 'DORA',
|
||||
ai_act: 'AI Act',
|
||||
mica: 'MiCA',
|
||||
mdr: 'Medizinprodukte (MDR)',
|
||||
maschinen: 'Maschinenverordnung',
|
||||
batterie: 'Batterieverordnung',
|
||||
ehds: 'EHDS',
|
||||
produktsicherheit: 'Produktsicherheit',
|
||||
dsa: 'Digital Services Act',
|
||||
dma: 'Digital Markets Act',
|
||||
data_governance: 'Data Governance Act',
|
||||
zahlungsdienste: 'Zahlungsdienste (PSD2)',
|
||||
geldwaesche: 'Geldwäsche (GwG)',
|
||||
lieferkette: 'Lieferkettengesetz',
|
||||
whistleblowing: 'Whistleblowing',
|
||||
barrierefreiheit: 'Barrierefreiheit (BFSG)',
|
||||
verbraucherschutz: 'Verbraucherschutz',
|
||||
urheberrecht: 'Urheberrecht',
|
||||
wettbewerbsrecht: 'Wettbewerbsrecht',
|
||||
gleichbehandlung: 'Gleichbehandlung (AGG)',
|
||||
steuerrecht: 'Steuerrecht',
|
||||
handelsrecht: 'Handelsrecht',
|
||||
}
|
||||
|
||||
export const MC_VERIFICATION_LABELS: Record<string, string> = {
|
||||
document: 'Dokument',
|
||||
source_code: 'Source Code',
|
||||
network: 'Netzwerk/Infra',
|
||||
it_process: 'IT-Prozess',
|
||||
hybrid: 'Hybrid',
|
||||
manual: 'Manuell',
|
||||
}
|
||||
|
||||
function humanize(key: string): string {
|
||||
return key.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
|
||||
}
|
||||
|
||||
export function useCaseLabel(key: string): string {
|
||||
return USE_CASE_LABELS[key] || humanize(key)
|
||||
}
|
||||
|
||||
export function mcVerificationLabel(key: string): string {
|
||||
return MC_VERIFICATION_LABELS[key] || humanize(key)
|
||||
}
|
||||
@@ -14,6 +14,11 @@ export interface ControlsMeta {
|
||||
category_counts?: Record<string, number>
|
||||
evidence_type_counts?: Record<string, number>
|
||||
release_state_counts?: Record<string, number>
|
||||
// Master-control mapping dimensions (only returned by the MC endpoint)
|
||||
use_case_counts?: Record<string, number>
|
||||
regulations?: Array<{ source_regulation: string; count: number }>
|
||||
mapped_total?: number
|
||||
unmapped_count?: number
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 50
|
||||
@@ -35,6 +40,10 @@ export function useControlLibraryState(backendUrlOverride?: string) {
|
||||
const [domainFilter, setDomainFilter] = useState<string>('')
|
||||
const [stateFilter, setStateFilter] = useState<string>('')
|
||||
const [verificationFilter, setVerificationFilter] = useState<string>('')
|
||||
const [useCaseFilter, setUseCaseFilter] = useState<string>('')
|
||||
const [primaryOnly, setPrimaryOnly] = useState<boolean>(false)
|
||||
const [regulationFilter, setRegulationFilter] = useState<string>('')
|
||||
const [mappedFilter, setMappedFilter] = useState<string>('')
|
||||
const [categoryFilter, setCategoryFilter] = useState<string>('')
|
||||
const [evidenceTypeFilter, setEvidenceTypeFilter] = useState<string>('')
|
||||
const [audienceFilter, setAudienceFilter] = useState<string>('')
|
||||
@@ -88,6 +97,10 @@ export function useControlLibraryState(backendUrlOverride?: string) {
|
||||
if (domainFilter) p.set('domain', domainFilter)
|
||||
if (stateFilter) p.set('release_state', stateFilter)
|
||||
if (verificationFilter) p.set('verification_method', verificationFilter)
|
||||
if (useCaseFilter) p.set('use_case', useCaseFilter)
|
||||
if (primaryOnly) p.set('primary', '1')
|
||||
if (regulationFilter) p.set('source_regulation', regulationFilter)
|
||||
if (mappedFilter) p.set('mapped', mappedFilter)
|
||||
if (categoryFilter) p.set('category', categoryFilter)
|
||||
if (evidenceTypeFilter) p.set('evidence_type', evidenceTypeFilter)
|
||||
if (audienceFilter) p.set('target_audience', audienceFilter)
|
||||
@@ -97,7 +110,7 @@ export function useControlLibraryState(backendUrlOverride?: string) {
|
||||
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])
|
||||
}, [severityFilter, domainFilter, stateFilter, verificationFilter, useCaseFilter, primaryOnly, regulationFilter, mappedFilter, categoryFilter, evidenceTypeFilter, audienceFilter, sourceFilter, typeFilter, hideDuplicates, debouncedSearch])
|
||||
|
||||
const loadFrameworks = useCallback(async () => {
|
||||
try {
|
||||
@@ -156,7 +169,7 @@ export function useControlLibraryState(backendUrlOverride?: string) {
|
||||
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])
|
||||
useEffect(() => { setCurrentPage(1) }, [severityFilter, domainFilter, stateFilter, verificationFilter, useCaseFilter, primaryOnly, regulationFilter, mappedFilter, categoryFilter, evidenceTypeFilter, audienceFilter, sourceFilter, typeFilter, hideDuplicates, debouncedSearch, sortBy])
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE))
|
||||
|
||||
@@ -212,6 +225,10 @@ export function useControlLibraryState(backendUrlOverride?: string) {
|
||||
domainFilter, setDomainFilter,
|
||||
stateFilter, setStateFilter,
|
||||
verificationFilter, setVerificationFilter,
|
||||
useCaseFilter, setUseCaseFilter,
|
||||
primaryOnly, setPrimaryOnly,
|
||||
regulationFilter, setRegulationFilter,
|
||||
mappedFilter, setMappedFilter,
|
||||
categoryFilter, setCategoryFilter,
|
||||
evidenceTypeFilter, setEvidenceTypeFilter,
|
||||
audienceFilter, setAudienceFilter,
|
||||
|
||||
@@ -232,6 +232,10 @@ export default function ControlLibraryPage() {
|
||||
domainFilter={state.domainFilter}
|
||||
stateFilter={state.stateFilter}
|
||||
verificationFilter={state.verificationFilter}
|
||||
useCaseFilter={state.useCaseFilter}
|
||||
primaryOnly={state.primaryOnly}
|
||||
regulationFilter={state.regulationFilter}
|
||||
mappedFilter={state.mappedFilter}
|
||||
categoryFilter={state.categoryFilter}
|
||||
evidenceTypeFilter={state.evidenceTypeFilter}
|
||||
audienceFilter={state.audienceFilter}
|
||||
@@ -243,6 +247,10 @@ export default function ControlLibraryPage() {
|
||||
setDomainFilter={state.setDomainFilter}
|
||||
setStateFilter={state.setStateFilter}
|
||||
setVerificationFilter={state.setVerificationFilter}
|
||||
setUseCaseFilter={state.setUseCaseFilter}
|
||||
setPrimaryOnly={state.setPrimaryOnly}
|
||||
setRegulationFilter={state.setRegulationFilter}
|
||||
setMappedFilter={state.setMappedFilter}
|
||||
setCategoryFilter={state.setCategoryFilter}
|
||||
setEvidenceTypeFilter={state.setEvidenceTypeFilter}
|
||||
setAudienceFilter={state.setAudienceFilter}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { ControlListView } from '../control-library/components/ControlListView'
|
||||
import { useControlLibraryState } from '../control-library/components/useControlLibraryState'
|
||||
import { useCaseLabel, mcVerificationLabel } from '../control-library/components/mcMappingLabels'
|
||||
|
||||
/**
|
||||
* Master Controls page — reuses the Control Library UI exactly,
|
||||
@@ -38,7 +39,7 @@ export default function MasterControlsPage() {
|
||||
if (state.mode === 'detail' && state.selectedControl) {
|
||||
return (
|
||||
<MCDetail
|
||||
mc={state.selectedControl}
|
||||
mc={state.selectedControl as unknown as Record<string, unknown>}
|
||||
onBack={() => { state.setMode('list'); state.setSelectedControl(null) }}
|
||||
/>
|
||||
)
|
||||
@@ -65,6 +66,10 @@ export default function MasterControlsPage() {
|
||||
domainFilter={state.domainFilter}
|
||||
stateFilter={state.stateFilter}
|
||||
verificationFilter={state.verificationFilter}
|
||||
useCaseFilter={state.useCaseFilter}
|
||||
primaryOnly={state.primaryOnly}
|
||||
regulationFilter={state.regulationFilter}
|
||||
mappedFilter={state.mappedFilter}
|
||||
categoryFilter={state.categoryFilter}
|
||||
evidenceTypeFilter={state.evidenceTypeFilter}
|
||||
audienceFilter={state.audienceFilter}
|
||||
@@ -76,6 +81,10 @@ export default function MasterControlsPage() {
|
||||
setDomainFilter={state.setDomainFilter}
|
||||
setStateFilter={state.setStateFilter}
|
||||
setVerificationFilter={state.setVerificationFilter}
|
||||
setUseCaseFilter={state.setUseCaseFilter}
|
||||
setPrimaryOnly={state.setPrimaryOnly}
|
||||
setRegulationFilter={state.setRegulationFilter}
|
||||
setMappedFilter={state.setMappedFilter}
|
||||
setCategoryFilter={state.setCategoryFilter}
|
||||
setEvidenceTypeFilter={state.setEvidenceTypeFilter}
|
||||
setAudienceFilter={state.setAudienceFilter}
|
||||
@@ -116,8 +125,15 @@ const SEV = {
|
||||
low: 'bg-blue-100 text-blue-800',
|
||||
} as Record<string, string>
|
||||
|
||||
interface MCMapping {
|
||||
use_cases?: Array<{ use_case: string; is_primary: boolean }>
|
||||
verification_method?: string | null
|
||||
regulations?: Array<{ source_regulation: string; is_primary: boolean; member_count: number }>
|
||||
}
|
||||
|
||||
function MCDetail({ mc, onBack }: { mc: Record<string, unknown>; onBack: () => void }) {
|
||||
const [members, setMembers] = useState<Member[]>([])
|
||||
const [mapping, setMapping] = useState<MCMapping>({})
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [phaseFilter, setPhaseFilter] = useState('')
|
||||
|
||||
@@ -131,6 +147,10 @@ function MCDetail({ mc, onBack }: { mc: Record<string, unknown>; onBack: () => v
|
||||
fetch(`/api/sdk/v1/master-controls?endpoint=control&id=${mcId}`)
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(data => {
|
||||
if (data) setMapping({
|
||||
use_cases: data.use_cases, verification_method: data.verification_method,
|
||||
regulations: data.regulations,
|
||||
})
|
||||
if (data?.members) setMembers(data.members)
|
||||
else if (data?.requirements) {
|
||||
// Fallback: parse requirements strings
|
||||
@@ -164,6 +184,33 @@ function MCDetail({ mc, onBack }: { mc: Record<string, unknown>; onBack: () => v
|
||||
<h1 className="text-2xl font-bold text-gray-900">{mcName}</h1>
|
||||
<p className="text-gray-500 mt-1">{mcId} — {totalControls} Atomic Controls</p>
|
||||
|
||||
{/* Zuordnung: Use Cases + Verifikation + Quell-Regulierung */}
|
||||
{(mapping.use_cases?.length || mapping.verification_method || mapping.regulations?.length) ? (
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||
{(mapping.use_cases || []).map(u => (
|
||||
<span key={u.use_case}
|
||||
className={`px-2 py-0.5 rounded text-xs font-medium ${u.is_primary
|
||||
? 'bg-purple-100 text-purple-800 border border-purple-300'
|
||||
: 'bg-purple-50 text-purple-600'}`}
|
||||
title={u.is_primary ? 'Primärzweck' : 'Mehrfachzweck'}>
|
||||
{useCaseLabel(u.use_case)}{u.is_primary ? ' ★' : ''}
|
||||
</span>
|
||||
))}
|
||||
{mapping.verification_method && (
|
||||
<span className="px-2 py-0.5 rounded text-xs font-medium bg-emerald-100 text-emerald-800 border border-emerald-300">
|
||||
Nachweis: {mcVerificationLabel(mapping.verification_method)}
|
||||
</span>
|
||||
)}
|
||||
{(mapping.regulations || []).slice(0, 4).map(r => (
|
||||
<span key={r.source_regulation}
|
||||
className="px-2 py-0.5 rounded text-xs bg-blue-50 text-blue-700"
|
||||
title={`${r.member_count} Member${r.is_primary ? ' · Primärquelle' : ''}`}>
|
||||
{r.source_regulation}{r.is_primary ? ' ★' : ''}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Phase badges */}
|
||||
<div className="flex flex-wrap gap-2 mt-4">
|
||||
{uniquePhases.map(p => (
|
||||
|
||||
Reference in New Issue
Block a user