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

106 lines
6.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, type ReactNode } from 'react';
import { Dialog } from '../../components/Dialog/Dialog';
import './compliance.css';
import type {
RegulatoryMappingCandidate,
RegulatoryMappingDecision,
RegulatoryMappingProposal,
RegulatoryMappingResponse
} from '../regulatory-mapping';
interface Props {
response?: RegulatoryMappingResponse;
loading: boolean;
error: string;
onStart(): void;
onDecision(control: Pick<RegulatoryMappingCandidate, 'controlId'>, decision: 'accepted' | 'rejected', comment?: string): void;
/** Inside the window the dialog title already names the mapping. */
embedded?: boolean;
}
/** Finding Reader `Map ISO controls` window, opened like `Accepted Risk`. */
export function RegulatoryMappingDialog({ onClose, ...props }: Omit<Props, 'embedded'> & { onClose(): void }) {
return <Dialog title="ISO control mapping" className="regulatory-mapping-dialog" onClose={onClose}>
<RegulatoryMappingPanel {...props} embedded/>
</Dialog>;
}
export function RegulatoryMappingPanel({ response, loading, error, onStart, onDecision, embedded = false }: Props) {
const run = response?.run;
const proposals = run?.result?.mappings ?? [];
const proposalIds = new Set(proposals.map(item => item.controlId));
const proposedCandidates = proposals.flatMap(proposal => {
const candidate = run?.candidates.find(item => item.controlId === proposal.controlId);
return candidate ? [{ candidate, proposal }] : [];
});
const otherCandidates = run?.candidates.filter(item => !proposalIds.has(item.controlId)) ?? [];
const earlierControls = response?.earlierControls ?? [];
return <section className={`regulatory-mapping-panel${embedded ? ' is-embedded' : ''}`} aria-label="ISO control mapping">
<header><div>{!embedded && <h3>ISO control mapping</h3>}<p>Proposals indicate relevance to this finding, not proof of compliance.</p></div>
{response?.generationEnabled && !['queued', 'running'].includes(response.state) && <button className="btn" type="button" disabled={loading} onClick={onStart}>{run ? 'Generate again' : 'Generate proposals'}</button>}
</header>
{loading && !response && <p role="status">Loading mapping state…</p>}
{error && <p className="regulatory-mapping-error" role="alert">{error}</p>}
{response && !response.generationEnabled && <p role="status">{response.disabledReason ?? 'Regulatory mapping is unavailable.'}</p>}
{response && ['queued', 'running'].includes(response.state) && <p role="status">{response.state === 'queued' ? 'Mapping queued…' : 'Retrieving and evaluating ISO controls…'}</p>}
{run?.status === 'failed' && <p className="regulatory-mapping-error" role="status">Mapping stopped safely ({run.errorCode ?? 'unknown error'}). You can start a new run.{run.errorDetail ? <><br/><span className="regulatory-mapping-error-detail">{run.errorDetail}</span></> : null}</p>}
{run?.status === 'succeeded' && run.result?.outcome !== 'mapped' && <p role="status">{run.result?.reason ?? (run.result?.outcome === 'insufficient_context' ? 'Insufficient public context.' : 'No relevant control was proposed.')}</p>}
{proposedCandidates.length > 0 && <div className="regulatory-control-list">
{proposedCandidates.map(item => <ControlCandidate key={item.candidate.controlId} {...item} decisions={response?.decisions ?? []} loading={loading} onDecision={onDecision}/>) }
</div>}
{otherCandidates.length > 0 && <details className="regulatory-other-candidates"><summary>Other retrieved controls ({otherCandidates.length})</summary>
<div className="regulatory-control-list">{otherCandidates.map(candidate => <ControlCandidate key={candidate.controlId} candidate={candidate} decisions={response?.decisions ?? []} loading={loading} onDecision={onDecision}/>)}</div>
</details>}
{earlierControls.length > 0 && <div className="regulatory-earlier-controls" aria-label="Earlier decisions">
<h4>Earlier decisions</h4>
<p>Decided in an earlier mapping run of this rule and not retrieved by the current run.</p>
<div className="regulatory-control-list">{earlierControls.map(control => <ControlDecision key={control.controlId} control={control}
heading={`${control.annexAControlId}${control.title ? ` — ${control.title}` : ''}`} decisions={response?.decisions ?? []} loading={loading} onDecision={onDecision}/>)}</div>
</div>}
</section>;
}
function ControlCandidate({ candidate, proposal, decisions, loading, onDecision }: {
candidate: RegulatoryMappingCandidate;
proposal?: RegulatoryMappingProposal;
decisions: RegulatoryMappingDecision[];
loading: boolean;
onDecision: Props['onDecision'];
}) {
return <ControlDecision control={candidate} heading={`${candidate.annexAControlId} — ${candidate.title}`} decisions={decisions} loading={loading} onDecision={onDecision}
meta={<>{proposal ? <>{proposal.relationship} · {proposal.confidence} confidence</> : 'Retrieved candidate'} · score {candidate.score.toFixed(3)}</>}>
{proposal?.rationale && <p>{proposal.rationale}</p>}
<details><summary>Evidence ({candidate.evidence.length})</summary>
<ul className="regulatory-evidence-list">{candidate.evidence.map(item => <li key={item.chunkId}>
{item.sourceStandard} · {item.sectionKind} · {item.pageStart === item.pageEnd ? `page ${item.pageStart}` : `pages ${item.pageStart}–${item.pageEnd}`}
</li>)}</ul>
</details>
</ControlDecision>;
}
/** One control with its latest decision, an optional comment and Accept/Reject. */
function ControlDecision({ control, heading, meta, children, decisions, loading, onDecision }: {
control: Pick<RegulatoryMappingCandidate, 'controlId'>;
heading: string;
meta?: ReactNode;
children?: ReactNode;
decisions: RegulatoryMappingDecision[];
loading: boolean;
onDecision: Props['onDecision'];
}) {
const [comment, setComment] = useState('');
const current = decisions.find(item => item.controlId === control.controlId);
return <article className="regulatory-control-candidate">
<header><div><h4>{heading}</h4>{meta && <p>{meta}</p>}</div>
{current && <span className="status-badge">{current.decision}</span>}
</header>
{children}
<label>Review comment (optional)<input value={comment} maxLength={1000} onChange={event => setComment(event.target.value)} /></label>
<div className="regulatory-decision-actions">
<button className="btn" type="button" disabled={loading} onClick={() => onDecision(control, 'accepted', comment)}>Accept</button>
<button className="btn" type="button" disabled={loading} onClick={() => onDecision(control, 'rejected', comment)}>Reject</button>
</div>
</article>;
}