Assessor_2.0/src/compliance/ui/ClientIsoControls.tsx

61 lines
3.6 KiB
TypeScript

import { useEffect, useId, useState } from 'react';
import { AssessorApiClient } from '../../api/assessor-api-client';
import type { BackendStatus } from '../../app/app-state';
import { useEnginePreferences } from '../../app/engine-preferences';
import type { RegulatoryControlFinding, RegulatoryControlOverviewResponse } from '../regulatory-mapping';
import { useRegulatoryDecisionRevision } from './decision-revision';
import './compliance.css';
const assessorApi = new AssessorApiClient();
interface Props {
projectId?: string;
assessmentIds?: readonly string[];
scanRunIds?: readonly string[];
backendStatus: BackendStatus;
onOpenFindings(findings: RegulatoryControlFinding[]): void;
}
type OverviewState = { status: 'idle' | 'loading' } | { status: 'error' } | { status: 'ready'; overview: RegulatoryControlOverviewResponse };
/** Client Info `ISO controls`: accepted controls of the timeslot's deviations, laid out like the Context heatmap. */
export function ClientIsoControls({ projectId, assessmentIds = [], scanRunIds = [], backendStatus, onOpenFindings }: Props) {
const available = useEnginePreferences().compliance;
const revision = useRegulatoryDecisionRevision();
const id = useId();
const [state, setState] = useState<OverviewState>({ status: 'idle' });
const scopeKey = JSON.stringify([projectId, assessmentIds, scanRunIds]);
const enabled = available && backendStatus === 'connected' && Boolean(projectId) && (assessmentIds.length > 0 || scanRunIds.length > 0);
useEffect(() => {
if (!enabled || !projectId) { setState({ status: 'idle' }); return; }
let active = true;
setState({ status: 'loading' });
assessorApi.getRegulatoryControlOverview(projectId, { assessmentIds: [...assessmentIds], scanRunIds: [...scanRunIds] })
.then(overview => { if (active) setState({ status: 'ready', overview }); })
.catch(() => { if (active) setState({ status: 'error' }); });
return () => { active = false; };
}, [enabled, revision, scopeKey]); // eslint-disable-line react-hooks/exhaustive-deps
if (!available || state.status === 'idle') return null;
return <section className="iso-controls" aria-labelledby={`${id}-title`} aria-busy={state.status === 'loading'}>
<header className="iso-controls__header">
<h2 id={`${id}-title`}>ISO controls</h2>
{state.status === 'ready' && <span className="iso-controls__total" aria-label="Unique findings with an accepted ISO control">
Findings: {state.overview.findingCount.toLocaleString('en-US')}</span>}
</header>
{state.status === 'error' && <p role="alert">ISO controls could not be loaded.</p>}
{state.status === 'ready' && !state.overview.controls.length && <p className="iso-controls__empty">No accepted ISO controls in this timeslot.</p>}
{state.status === 'ready' && state.overview.controls.length > 0 && <div className="iso-controls__grid" aria-label="Accepted ISO controls">
{state.overview.controls.map(control => <button key={control.controlId} type="button" className="iso-controls__cell"
data-control={control.controlId}
aria-label={`Open ${control.annexAControlId} findings in Work (${control.findings.length})`}
onClick={() => onOpenFindings(control.findings)}>
{control.theme && <span className="iso-controls__category">{control.theme}</span>}
<strong>{control.annexAControlId}{control.title ? ` · ${control.title}` : ''}</strong>
<span className="iso-controls__amount">{control.findings.length.toLocaleString('en-US')} {control.findings.length === 1 ? 'finding' : 'findings'}</span>
</button>)}
</div>}
</section>;
}