import { useCallback, useEffect, useRef, useState } from 'react'; import { AssessorApiClient } from '../../api/assessor-api-client'; import { useEnginePreferences } from '../../app/engine-preferences'; import { useBackendWorkActivity } from './use-backend-work-activity'; import { statusPollApi } from './status-poll-client'; import { Dialog } from '../../components/Dialog/Dialog'; import type { RegulatoryFramework, RegulatoryMappingBatchResponse, RegulatoryMappingBatchScope } from '../regulatory-mapping'; const assessorApi = new AssessorApiClient(); export interface RegulatoryMappingBatchController { /** False while the Compliance Engine is switched off. */ available: boolean; response?: RegulatoryMappingBatchResponse; loading: boolean; starting: boolean; error?: string; start(): Promise; } /** The ISO batch, with the direct BSI batch of the same filters beside it (Phase 13F, BSI stage 2). */ export function useRegulatoryMappingBatch(requestedProjectId: string | undefined, scope: RegulatoryMappingBatchScope | undefined): RegulatoryMappingBatchController & { bsi: RegulatoryMappingBatchController } { const iso = useFrameworkBatch(requestedProjectId, scope, 'iso27001'); const bsi = useFrameworkBatch(requestedProjectId, scope, 'bsi-kernel'); return { ...iso, bsi }; } function useFrameworkBatch(requestedProjectId: string | undefined, scope: RegulatoryMappingBatchScope | undefined, framework: RegulatoryFramework): RegulatoryMappingBatchController { // A switched-off Compliance Engine requests no batch status and offers no batch action. const available = useEnginePreferences().compliance; const projectId = available ? requestedProjectId : undefined; const [response, setResponse] = useState(); const [loading, setLoading] = useState(false); const [starting, setStarting] = useState(false); const [error, setError] = useState(); const scopeKey = JSON.stringify(scope ?? null); const requestKey = `${projectId ?? ''}\u0000${scopeKey}\u0000${framework}`; const activeRequestKey = useRef(requestKey); activeRequestKey.current = requestKey; const refresh = useCallback(async (poll = false) => { if (!projectId || !scope || (!scope.assessmentIds.length && !scope.scanRunIds.length)) { setResponse(undefined); return; } try { const next = await (poll ? statusPollApi : assessorApi).getRegulatoryMappingBatch(projectId, scope, framework); if (activeRequestKey.current !== requestKey) return; setResponse(next); setError(undefined); } catch (reason) { if (activeRequestKey.current !== requestKey) return; setError(reason instanceof Error ? reason.message : 'Compliance mapping batch status could not be loaded.'); } }, [projectId, requestKey, scopeKey]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { let active = true; setLoading(true); setStarting(false); setError(undefined); setResponse(undefined); void refresh().finally(() => { if (active) setLoading(false); }); return () => { active = false; }; }, [refresh, scopeKey]); useEffect(() => { if (!response || !['queued', 'running'].includes(response.state)) return; const timer = window.setTimeout(() => void refresh(true), response.pollAfterMs ?? 1_500); return () => window.clearTimeout(timer); }, [refresh, response]); const start = useCallback(async () => { if (!projectId || !scope) return; setStarting(true); setError(undefined); try { const next = await assessorApi.startRegulatoryMappingBatch(projectId, { ...scope, idempotencyKey: `regulatory-batch-${crypto.randomUUID()}`, ...(framework === 'iso27001' ? {} : { framework }) }); if (activeRequestKey.current !== requestKey) return; setResponse(next); } catch (reason) { if (activeRequestKey.current !== requestKey) return; setError(reason instanceof Error ? reason.message : `${framework === 'bsi-kernel' ? 'BSI' : 'ISO'} mapping batch could not be started.`); } finally { if (activeRequestKey.current === requestKey) setStarting(false); } }, [projectId, requestKey, scopeKey]); // eslint-disable-line react-hooks/exhaustive-deps useBackendWorkActivity(framework === 'bsi-kernel' ? 'Mapping all BSI requirements' : 'Mapping all ISO controls', !response || response.state === 'empty' ? 'idle' : response.state === 'completed' ? 'finished' : response.state); return { available, response, loading, starting, error, start }; } export function RegulatoryMappingBatchDialog({ controller, filteredResultCount, onClose }: { controller: RegulatoryMappingBatchController & { bsi?: RegulatoryMappingBatchController }; filteredResultCount: number; onClose(): void; }) { return

Generate ISO control proposals for all {filteredResultCount.toLocaleString('en-US')} results matching the current Result DB filters.

Pagination, “Load more,” and loaded-row limits do not restrict this batch. Proposals remain subject to manual Accept/Reject review.

{/* User decision 2026-10-09: the direct BSI mapping of the same filters, compared with ISO in the Client Info Compliance tab. */} {controller.bsi && <>

BSI Grundschutz++ requirements

Map the same results directly to BSI Kernel requirements. The Compliance tab compares them with the BSI requirements derived from the accepted ISO controls.

}
; } function BatchSection({ controller, filteredResultCount, startLabel = 'Start mapping batch' }: { controller: RegulatoryMappingBatchController; filteredResultCount: number; startLabel?: string; }) { const { response, loading, starting, error, start } = controller; const run = response?.run; const active = response && ['queued', 'running'].includes(response.state); const counts = run?.counts; const actionLabel = run ? 'Run again for current filters' : startLabel; return <> {loading && !response &&

Loading batch status…

} {error &&

{error}

} {response && !response.generationEnabled &&

{response.disabledReason ?? 'Regulatory mapping is unavailable.'}

} {run && <>

Status: {run.status === 'completed' && run.counts.failedRuleCount ? 'completed with failures' : run.status}

{run.errorDetail &&

{run.errorDetail}

} } {active &&

Mapping is running on the server. You can close this dialog and return later.

} {response?.generationEnabled &&
} ; } function BatchCount({ label, value }: { label: string; value: number }) { return
{label}
{value.toLocaleString('en-US')}
; }