import { useEffect, useId, useState, type ReactNode } from 'react'; import { AssessorApiClient } from '../../api/assessor-api-client'; import type { BackendStatus } from '../../app/app-state'; import { useEnginePreferences } from '../../app/engine-preferences'; import { DashboardGlyph, type DashboardFact } from '../../features/dashboard/ClientTileParts'; import { EnvironmentResultsButton } from '../../features/dashboard/EnvironmentResultsButton'; import type { RegulatoryControlOverviewItem, RegulatoryControlOverviewResponse, RegulatoryControlTheme, RegulatoryMappingHistoryEntry } from '../regulatory-mapping'; import { useRegulatoryDecisionRevision } from './decision-revision'; import { bsiRequirementFindings, type BsiOverview } from '../bsi-grundschutz'; import { ComplianceFrameworkSwitch, ComplianceOverview, type ComplianceFramework } from './ComplianceOverview'; import type { ComplianceMappingState } from './compliance-work-facets'; import { MappingReviewTile } from './MappingReviewTile'; import './compliance.css'; const assessorApi = new AssessorApiClient(); const THEMES: RegulatoryControlTheme[] = ['Organizational', 'People', 'Physical', 'Technological']; export const ISO_TILE_TITLE = 'ISO 27001/27002'; export const BSI_TILE_TITLE = 'BSI Grundschutz++'; interface Props { projectId?: string; assessmentIds?: readonly string[]; scanRunIds?: readonly string[]; backendStatus: BackendStatus; /** Opens Work filtered to this accepted control, like a Context heatmap cell. */ onOpenControl(controlId: string): void; /** Opens Work filtered to this ISO theme. */ onOpenTheme(theme: RegulatoryControlTheme): void; /** Opens Work filtered to this derived BSI requirement. */ onOpenBsiRequirement(requirementId: string): void; /** Opens Work filtered to this BSI practice. */ onOpenBsiPractice(practiceId: string): void; /** Opens Work with the deviations in this `ISO mapping` facet state. */ onOpenMappingState(state: ComplianceMappingState): void; /** The Client Info context row (client and timeslot), shown below the tab bar like on the Overview. */ context?: ReactNode; /** The Client Info tab bar, shown above this tab like on the AI Overview. */ navigation?: ReactNode; } type OverviewState = { status: 'idle' | 'loading' } | { status: 'error' } | { status: 'ready'; overview: RegulatoryControlOverviewResponse }; const number = (value: number) => value.toLocaleString('en-US'); const count = (value: number, noun: string) => `${number(value)} ${noun}${value === 1 ? '' : 's'}`; const byFindings = (left: RegulatoryControlOverviewItem, right: RegulatoryControlOverviewItem) => right.findings.length - left.findings.length || left.controlId.localeCompare(right.controlId, 'en', { numeric: true }); interface ThemeGroup { theme?: RegulatoryControlTheme; controls: RegulatoryControlOverviewItem[]; findings: number } /** Themes in ISO 27002 order, only with hits; a control without a known theme follows without a drilldown. */ function themeGroups(controls: RegulatoryControlOverviewItem[]): ThemeGroup[] { return [...THEMES, undefined] .map(theme => { const themeControls = controls.filter(control => control.theme === theme).sort(byFindings); // A finding with several controls of one theme counts once for that theme. const findings = new Set(themeControls.flatMap(control => control.findings.map(finding => finding.resultProjectionId))).size; return { theme, controls: themeControls, findings }; }) .filter(group => group.controls.length > 0); } /** * Client Info `Compliance` tab: an overview row (mapping coverage and Annex A footprint), one tile per framework * in the environment tile geometry, the complete control list in its Results window, and when the timeslot was * last mapped. ISO/IEC 27001/27002 is the first framework. */ export function ClientIsoControls({ projectId, assessmentIds = [], scanRunIds = [], backendStatus, onOpenControl, onOpenTheme, onOpenBsiRequirement, onOpenBsiPractice, onOpenMappingState, navigation, context }: Props) { const available = useEnginePreferences().compliance; const revision = useRegulatoryDecisionRevision(); const id = useId(); const scopeKey = JSON.stringify([projectId, assessmentIds, scanRunIds]); const enabled = available && backendStatus === 'connected' && Boolean(projectId) && (assessmentIds.length > 0 || scanRunIds.length > 0); // Returning to the tab shows the last overview of this timeslot at once; the refresh below replaces it. const [state, setState] = useState(() => { const cached = enabled ? overviewCache.get(scopeKey) : undefined; return cached ? { status: 'ready', overview: cached } : { status: 'idle' }; }); const [poll, setPoll] = useState(0); // Which framework the overview row shows; the framework tiles below are unaffected. const [framework, setFramework] = useState('iso'); useEffect(() => { if (!enabled || !projectId) { setState({ status: 'idle' }); return; } let active = true; const cached = overviewCache.get(scopeKey); // A poll or a remembered overview keeps the shown values until the refreshed overview arrives. if (cached) setState({ status: 'ready', overview: cached }); else if (poll === 0) setState({ status: 'loading' }); // A preload still in flight answers the first visit; every later visit and poll requests a fresh overview. const pending = poll === 0 ? overviewRequests.get(scopeKey) : undefined; (pending ?? requestOverview(projectId, assessmentIds, scanRunIds)) .then(overview => { if (active) setState({ status: 'ready', overview }); }) .catch(() => { if (active && !cached && poll === 0) setState({ status: 'error' }); }); return () => { active = false; }; }, [enabled, revision, scopeKey, poll]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { setPoll(0); }, [enabled, revision, scopeKey]); const history = state.status === 'ready' ? state.overview.mappingHistory ?? [] : []; const mappingActive = history.some(entry => entry.status === 'queued' || entry.status === 'running'); // While a mapping of this timeslot runs, its progress and newly accepted controls appear without a reload. useEffect(() => { if (!mappingActive) return; const timer = setTimeout(() => setPoll(value => value + 1), MAPPING_POLL_MS); return () => clearTimeout(timer); }, [mappingActive, state]); if (!available || state.status === 'idle') return null; const groups = state.status === 'ready' ? themeGroups(state.overview.controls) : []; // Pending values stay an em dash, never zero; all four themes follow the totals in ISO order. const ready = state.status === 'ready'; const totals: DashboardFact[] = [ { label: 'Controls', value: ready ? number(state.overview.controls.length) : '—' }, { label: 'Findings', value: ready ? number(state.overview.findingCount) : '—' }]; const themeFindings = (theme: RegulatoryControlTheme) => groups.find(group => group.theme === theme)?.findings ?? 0; // Same cell markup as the shared fact grid; a theme with hits opens Work filtered to it. const tileFacts =
{totals.map(fact =>
{fact.label}
{fact.value}
)} {THEMES.map(theme => { const findings = themeFindings(theme); return
0 ? 'iso-tile-theme is-actionable' : 'iso-tile-theme'} data-theme={theme}>
{theme}
{!ready ? '—' : findings > 0 ? : count(0, 'finding')}
; })}
; const results = (dismiss: () => void) => { const open = (action: () => void) => { dismiss(); action(); }; if (state.status !== 'ready') return null; if (!groups.length) return

No accepted ISO controls in this timeslot.

; return

Findings: {number(state.overview.findingCount)}

{groups.map(({ theme, controls, findings }) => { const summary = <>{theme ?? 'No ISO theme'} {count(controls.length, 'control')} · {count(findings, 'finding')}; return
{theme ? :
{summary}
}
{controls.map(control => )}
; })}
; }; return
{navigation} {context}

Compliance

{state.status !== 'error' && }
{state.status === 'ready' && }
{state.status !== 'error' && } {/* Same tile geometry as the environment row; later frameworks add their tiles here. */}

{ISO_TILE_TITLE}

{state.status === 'ready' && {results}}
{state.status === 'error' ?

ISO controls could not be loaded.

: tileFacts}
{/* User decision 2026-10-09: ISO <-> BSI consistency review beside the framework tiles. */} {projectId && }
; } // Last overview per client and timeslot, so the tab does not start empty on every visit. Every visit, // decision and running mapping still requests a fresh overview; the bound keeps a few recent timeslots. const OVERVIEW_CACHE_LIMIT = 8; const overviewCache = new Map(); const overviewRequests = new Map>(); function requestOverview(projectId: string, assessmentIds: readonly string[], scanRunIds: readonly string[]) { const scopeKey = JSON.stringify([projectId, assessmentIds, scanRunIds]); const request = assessorApi.getRegulatoryControlOverview(projectId, { assessmentIds: [...assessmentIds], scanRunIds: [...scanRunIds] }) .then(overview => { rememberOverview(scopeKey, overview); return overview; }) .finally(() => { if (overviewRequests.get(scopeKey) === request) overviewRequests.delete(scopeKey); }); overviewRequests.set(scopeKey, request); return request; } /** * Client Info calls this when it opens a timeslot, so the Compliance tab already shows its overview on the first * visit. A remembered or already requested overview is not requested again; a failure leaves the tab to load itself. */ export function preloadClientComplianceOverview(projectId: string, assessmentIds: readonly string[] = [], scanRunIds: readonly string[] = []): void { const scopeKey = JSON.stringify([projectId, assessmentIds, scanRunIds]); if (overviewCache.has(scopeKey) || overviewRequests.has(scopeKey)) return; requestOverview(projectId, assessmentIds, scanRunIds).catch(() => undefined); } function rememberOverview(scopeKey: string, overview: RegulatoryControlOverviewResponse) { overviewCache.delete(scopeKey); overviewCache.set(scopeKey, overview); while (overviewCache.size > OVERVIEW_CACHE_LIMIT) overviewCache.delete(overviewCache.keys().next().value!); } /** Test support. */ export function resetClientComplianceCacheForTests(): void { overviewCache.clear(); overviewRequests.clear(); } const MAPPING_POLL_MS = 5_000; const triggerLabel = (entry: RegulatoryMappingHistoryEntry) => entry.kind === 'single' ? 'Single finding' : entry.trigger === 'automatic' ? 'Automatic' : 'Manual'; const FRAMEWORK_LABEL = { iso27001: 'ISO 27001/27002', 'bsi-kernel': 'BSI Grundschutz++' } as const; const SCOPE_LABEL = { timeslot: 'This timeslot', 'other-timeslot': 'Other timeslot', 'other-client': 'Other client', unknown: 'Unknown' } as const; const STATUS_LABEL = { queued: 'Queued', running: 'Running', completed: 'Completed', failed: 'Failed' } as const; const dateTime = (value: string) => new Date(value).toLocaleString('en-GB', { day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' }); /** * The latest mapping of any client and what started it; mappings are global per rule. The window lists the latest * ISO and direct BSI batches and single finding mappings with their framework, scope and rule counts (user decision * 2026-10-09); the button names BSI, an ISO mapping keeps its earlier label. */ function LastMapping({ history }: { history: RegulatoryMappingHistoryEntry[] }) { const latest = history[0]; if (!latest) return No mapping yet; const label = `Last mapping: ${dateTime(latest.createdAt)}${latest.framework === 'bsi-kernel' ? ' · BSI' : ''} · ${triggerLabel(latest)}` + (latest.scope === 'timeslot' ? '' : ` · ${SCOPE_LABEL[latest.scope]}`) + (latest.status === 'completed' ? '' : ` · ${STATUS_LABEL[latest.status]}`); return
{history.map(entry => )}
StartedFrameworkTriggerScopeStatusFindingsSent to modelReusedSucceededFailed
{dateTime(entry.createdAt)} {FRAMEWORK_LABEL[entry.framework ?? 'iso27001']} {triggerLabel(entry)} {SCOPE_LABEL[entry.scope]} {STATUS_LABEL[entry.status]} {entry.counts.findings === undefined ? '—' : number(entry.counts.findings)} {number(entry.counts.sentToModel)} {number(entry.counts.reused)} {number(entry.counts.succeeded)} {number(entry.counts.failed)}
; } const BSI_TILE_PRACTICES = 4; interface BsiPracticeGroup { id: string; title: string; requirements: Array; findings: number } /** Practices in catalog order, only with derived requirements; a finding counts once per practice. */ function bsiPracticeGroups(overview: RegulatoryControlOverviewResponse): { groups: BsiPracticeGroup[]; findings: number } { const bsi = overview.bsi; if (!bsi) return { groups: [], findings: 0 }; const findingsOf = bsiRequirementFindings(overview.controls, bsi); const all = new Set(); const groups = bsi.practices.map(practice => { const practiceFindings = new Set(); const requirements = bsi.requirements.filter(requirement => requirement.practice === practice.id).map(requirement => { const findings = findingsOf.get(requirement.requirementId) ?? []; for (const finding of findings) { practiceFindings.add(finding.resultProjectionId); all.add(finding.resultProjectionId); } return { ...requirement, findings: findings.length }; }).sort((left, right) => right.findings - left.findings || left.requirementId.localeCompare(right.requirementId, 'en', { numeric: true })); return { ...practice, requirements, findings: practiceFindings.size }; }).filter(group => group.requirements.length > 0); return { groups, findings: all.size }; } /** * BSI Grundschutz++ tile (Phase 13F, stage 1): Kernel requirements derived from the accepted ISO controls through * the BSI mapping. The four practices with the most findings follow the totals; the Results window lists all. */ function BsiTile({ state, onOpenRequirement, onOpenPractice }: { state: OverviewState; onOpenRequirement(id: string): void; onOpenPractice(id: string): void }) { const ready = state.status === 'ready'; const { groups, findings } = ready ? bsiPracticeGroups(state.overview) : { groups: [], findings: 0 }; const bsi = ready ? state.overview.bsi : undefined; const requirementCount = groups.reduce((total, group) => total + group.requirements.length, 0); const top = [...groups].sort((left, right) => right.findings - left.findings).slice(0, BSI_TILE_PRACTICES); const results = (dismiss: () => void) => { const open = (action: () => void) => { dismiss(); action(); }; if (!groups.length || !bsi) return

No BSI requirements derived from accepted ISO controls in this timeslot.

; return

Findings: {number(findings)}

{groups.map(group =>
{group.requirements.map(requirement => )}
)}

Derived from the accepted ISO controls through the BSI mapping ISO/IEC 27001 Annex A to Grundschutz++ {bsi.crosswalkVersion}, catalog {bsi.catalogVersion.slice(0, 10)}. A requirement is reached when one of its ISO controls is accepted; it is no decision of its own. {bsi.missingTargets.length > 0 && ` Not in the catalog: ${[...new Set(bsi.missingTargets.map(item => item.requirementId))].join(', ')}.`} {bsi.license}

; }; return

{BSI_TILE_TITLE}

{ready && {results}}
{state.status === 'error' ?

BSI requirements could not be loaded.

:
Requirements
{ready ? number(requirementCount) : '—'}
Findings
{ready ? number(findings) : '—'}
{top.map(group =>
{group.title}
)}
}
; }