61 lines
3.6 KiB
TypeScript
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>;
|
|
}
|