119 lines
5.1 KiB
TypeScript
119 lines
5.1 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import type { BackendStatus } from '../../app/app-state';
|
|
import { AssessorApiClient } from '../../api/assessor-api-client';
|
|
import type {
|
|
RegulatoryMappingCandidate,
|
|
RegulatoryMappingDecisionValue,
|
|
RegulatoryMappingResponse
|
|
} from '../../domain/regulatory-mapping';
|
|
|
|
const assessorApi = new AssessorApiClient();
|
|
|
|
export function useRegulatoryMapping(
|
|
projectId: string,
|
|
occurrenceId: string | undefined,
|
|
backendStatus: BackendStatus
|
|
) {
|
|
const [open, setOpen] = useState(false);
|
|
const [storedResponse, setStoredResponse] = useState<{ scope: { key: string }; value: RegulatoryMappingResponse }>();
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState('');
|
|
const resultProjectionId = occurrenceId ? `scanner-finding:${occurrenceId}` : '';
|
|
const scopeKey = `${projectId}\u0000${resultProjectionId}\u0000${backendStatus}`;
|
|
const activeScope = useRef({ key: scopeKey });
|
|
if (activeScope.current.key !== scopeKey) activeScope.current = { key: scopeKey };
|
|
const requestScope = activeScope.current;
|
|
const response = storedResponse?.scope === requestScope ? storedResponse.value : undefined;
|
|
|
|
useEffect(() => {
|
|
setOpen(false);
|
|
setStoredResponse(undefined);
|
|
setLoading(false);
|
|
setError('');
|
|
}, [scopeKey]);
|
|
|
|
const load = useCallback(async () => {
|
|
if (!projectId || !resultProjectionId || backendStatus !== 'connected' || activeScope.current !== requestScope) return;
|
|
setLoading(true);
|
|
try {
|
|
const next = await assessorApi.getRegulatoryMappings(projectId, resultProjectionId);
|
|
if (activeScope.current !== requestScope) return;
|
|
setStoredResponse({ scope: requestScope, value: next });
|
|
setError('');
|
|
} catch (reason) {
|
|
if (activeScope.current !== requestScope) return;
|
|
setError(reason instanceof Error ? reason.message : 'Regulatory mapping could not be loaded.');
|
|
} finally {
|
|
if (activeScope.current === requestScope) setLoading(false);
|
|
}
|
|
}, [backendStatus, projectId, requestScope, resultProjectionId]);
|
|
|
|
useEffect(() => { if (open && !response && !loading && !error) void load(); }, [error, load, loading, open, response]);
|
|
useEffect(() => {
|
|
if (!open || !response || !['queued', 'running'].includes(response.state)) return;
|
|
const timeout = window.setTimeout(() => void load(), response.pollAfterMs ?? 1_500);
|
|
return () => window.clearTimeout(timeout);
|
|
}, [load, open, response]);
|
|
|
|
const start = useCallback(async () => {
|
|
if (!projectId || !resultProjectionId || activeScope.current !== requestScope) return;
|
|
setLoading(true);
|
|
try {
|
|
const next = await assessorApi.generateRegulatoryMappings(projectId, { resultProjectionId });
|
|
if (activeScope.current !== requestScope) return;
|
|
setStoredResponse({ scope: requestScope, value: next });
|
|
setError('');
|
|
} catch (reason) {
|
|
if (activeScope.current !== requestScope) return;
|
|
setError(reason instanceof Error ? reason.message : 'Regulatory mapping could not be started.');
|
|
} finally {
|
|
if (activeScope.current === requestScope) setLoading(false);
|
|
}
|
|
}, [projectId, requestScope, resultProjectionId]);
|
|
|
|
const decide = useCallback(async (candidate: RegulatoryMappingCandidate, decision: RegulatoryMappingDecisionValue, comment?: string) => {
|
|
if (!projectId || !resultProjectionId || !response?.run || activeScope.current !== requestScope) return;
|
|
const latest = response.decisions.find(item => item.controlId === candidate.controlId);
|
|
setLoading(true);
|
|
try {
|
|
const next = await assessorApi.saveRegulatoryMappingDecision(projectId, {
|
|
resultProjectionId,
|
|
controlId: candidate.controlId,
|
|
decision,
|
|
...(comment?.trim() ? { comment: comment.trim() } : {}),
|
|
...(latest ? { expectedLatestDecisionId: latest.id } : {})
|
|
});
|
|
if (activeScope.current !== requestScope) return;
|
|
setStoredResponse({ scope: requestScope, value: next });
|
|
setError('');
|
|
} catch (reason) {
|
|
if (activeScope.current !== requestScope) return;
|
|
setError(reason instanceof Error ? reason.message : 'The mapping decision could not be saved.');
|
|
} finally {
|
|
if (activeScope.current === requestScope) setLoading(false);
|
|
}
|
|
}, [projectId, requestScope, response, resultProjectionId]);
|
|
|
|
const acceptedCount = useMemo(() => {
|
|
const latest = new Map<string, RegulatoryMappingDecisionValue>();
|
|
response?.decisions.forEach(item => { if (!latest.has(item.controlId)) latest.set(item.controlId, item.decision); });
|
|
return [...latest.values()].filter(value => value === 'accepted').length;
|
|
}, [response]);
|
|
|
|
const localDisabledReason = !occurrenceId ? 'This record has no canonical finding occurrence.'
|
|
: backendStatus !== 'connected' ? 'The backend is not connected.'
|
|
: response && !response.generationEnabled ? response.disabledReason ?? 'Regulatory mapping is unavailable.' : undefined;
|
|
|
|
return {
|
|
open,
|
|
setOpen,
|
|
response,
|
|
loading,
|
|
error,
|
|
start,
|
|
decide,
|
|
acceptedCount,
|
|
disabledReason: localDisabledReason
|
|
};
|
|
}
|