73 lines
4.8 KiB
TypeScript
73 lines
4.8 KiB
TypeScript
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>;
|
||
}
|