Assessor_2.0/src/features/finding-reader/RegulatoryMappingPanel.tsx

73 lines
4.8 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 } from 'react';
import type {
RegulatoryMappingCandidate,
RegulatoryMappingDecision,
RegulatoryMappingProposal,
RegulatoryMappingResponse
} from '../../domain/regulatory-mapping';
interface Props {
response?: RegulatoryMappingResponse;
loading: boolean;
error: string;
onStart(): void;
onDecision(candidate: RegulatoryMappingCandidate, decision: 'accepted' | 'rejected', comment?: string): void;
}
export function RegulatoryMappingPanel({ response, loading, error, onStart, onDecision }: 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)) ?? [];
return <section className="regulatory-mapping-panel" aria-label="ISO control mapping">
<header><div><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>}
</section>;
}
function ControlCandidate({ candidate, proposal, decisions, loading, onDecision }: {
candidate: RegulatoryMappingCandidate;
proposal?: RegulatoryMappingProposal;
decisions: RegulatoryMappingDecision[];
loading: boolean;
onDecision(candidate: RegulatoryMappingCandidate, decision: 'accepted' | 'rejected', comment?: string): void;
}) {
const [comment, setComment] = useState('');
const current = decisions.find(item => item.controlId === candidate.controlId);
return <article className="regulatory-control-candidate">
<header><div><h4>{candidate.annexAControlId} — {candidate.title}</h4>
<p>{proposal ? <>{proposal.relationship} · {proposal.confidence} confidence</> : 'Retrieved candidate'} · score {candidate.score.toFixed(3)}</p></div>
{current && <span className="status-badge">{current.decision}</span>}
</header>
{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>
<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(candidate, 'accepted', comment)}>Accept</button>
<button className="btn" type="button" disabled={loading} onClick={() => onDecision(candidate, 'rejected', comment)}>Reject</button>
</div>
</article>;
}