162 lines
11 KiB
TypeScript
162 lines
11 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { AssessorApiClient } from '../../api/assessor-api-client';
|
|
import type { BackendStatus } from '../../app/app-state';
|
|
import { DashboardGlyph } from '../../features/dashboard/ClientTileParts';
|
|
import { EnvironmentResultsButton } from '../../features/dashboard/EnvironmentResultsButton';
|
|
import type { BsiConsistencyState, RegulatoryConsistencyItem, RegulatoryConsistencyResponse } from '../bsi-consistency';
|
|
import { useRegulatoryDecisionRevision } from './decision-revision';
|
|
import { CONSISTENCY_LABELS } from './FindingBsiRequirements';
|
|
import { useRegulatoryMapping } from './use-regulatory-mapping';
|
|
import './compliance.css';
|
|
|
|
const assessorApi = new AssessorApiClient();
|
|
export const MAPPING_REVIEW_TITLE = 'ISO ↔ BSI review';
|
|
const number = (value: number) => value.toLocaleString('en-US');
|
|
const FILTERS: Array<{ state: BsiConsistencyState; label: string }> = [
|
|
{ state: 'inconsistent', label: 'Inconsistent' }, { state: 'no-reference', label: 'No ISO reference' }, { state: 'consistent', label: 'Consistent' }];
|
|
|
|
type ReviewState = { status: 'loading' } | { status: 'error' } | { status: 'ready'; review: RegulatoryConsistencyResponse };
|
|
|
|
/**
|
|
* Client Info `Compliance` tab tile of the ISO <-> BSI consistency review (Phase 13F, BSI stage 2, user decision
|
|
* 2026-10-09): per rule of the timeslot's deviations, the directly accepted BSI requirements against the ones the BSI
|
|
* mapping derives from the accepted ISO controls. The Results window lists the rules for a person to review; a
|
|
* disagreement is only shown and changes no decision by itself.
|
|
*/
|
|
export function MappingReviewTile({ projectId, assessmentIds, scanRunIds, backendStatus }: {
|
|
projectId: string;
|
|
assessmentIds: readonly string[];
|
|
scanRunIds: readonly string[];
|
|
backendStatus: BackendStatus;
|
|
}) {
|
|
const revision = useRegulatoryDecisionRevision();
|
|
const [state, setState] = useState<ReviewState>({ status: 'loading' });
|
|
const [reload, setReload] = useState(0);
|
|
const scopeKey = JSON.stringify([projectId, assessmentIds, scanRunIds]);
|
|
useEffect(() => {
|
|
let active = true;
|
|
// A refresh keeps the shown values until the new review arrives; only the first load shows em dashes.
|
|
setState(current => current.status === 'ready' && current.review.projectId === projectId ? current : { status: 'loading' });
|
|
assessorApi.getRegulatoryMappingConsistency(projectId, { assessmentIds: [...assessmentIds], scanRunIds: [...scanRunIds] })
|
|
.then(review => { if (active) setState({ status: 'ready', review }); })
|
|
.catch(() => { if (active) setState(current => current.status === 'ready' ? current : { status: 'error' }); });
|
|
return () => { active = false; };
|
|
}, [scopeKey, revision, reload]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
const ready = state.status === 'ready';
|
|
const counts = ready ? state.review.counts : undefined;
|
|
const value = (amount: number | undefined) => amount === undefined ? '—' : number(amount);
|
|
const compared = counts ? counts.consistent + counts.inconsistent + counts.noReference : undefined;
|
|
return <section className="client-env-group" data-framework="iso-bsi-review">
|
|
<header className="client-env-group-header">
|
|
<h3 className="client-env-group-title"><DashboardGlyph name="clipboard"/><span>{MAPPING_REVIEW_TITLE}</span></h3>
|
|
{ready && <EnvironmentResultsButton title={MAPPING_REVIEW_TITLE}>
|
|
<ReviewList review={state.review} projectId={projectId} backendStatus={backendStatus} onChanged={() => setReload(current => current + 1)}/>
|
|
</EnvironmentResultsButton>}
|
|
</header>
|
|
{state.status === 'error' ? <p role="alert" className="iso-controls__error">The ISO ↔ BSI review could not be loaded.</p>
|
|
: <dl className="client-env-facts">
|
|
<div><dt>Compared rules</dt><dd>{value(compared)}</dd></div>
|
|
<div><dt>Inconsistent</dt><dd>{value(counts?.inconsistent)}</dd></div>
|
|
<div><dt>No ISO reference</dt><dd>{value(counts?.noReference)}</dd></div>
|
|
<div><dt>Consistent</dt><dd>{value(counts?.consistent)}</dd></div>
|
|
<div><dt>Not compared</dt><dd>{value(counts?.notCompared)}</dd></div>
|
|
<div><dt>Reviewed</dt><dd>{value(counts?.reviewed)}</dd></div>
|
|
</dl>}
|
|
</section>;
|
|
}
|
|
|
|
function ReviewList({ review, projectId, backendStatus, onChanged }: {
|
|
review: RegulatoryConsistencyResponse; projectId: string; backendStatus: BackendStatus; onChanged(): void;
|
|
}) {
|
|
const [filter, setFilter] = useState<BsiConsistencyState>('inconsistent');
|
|
const items = review.items.filter(item => item.state === filter);
|
|
return <div className="mapping-review">
|
|
<p className="mapping-review__intro">Each rule compares the BSI requirements accepted in its direct BSI mapping with the requirements the BSI
|
|
mapping {review.crosswalkVersion} derives from its accepted ISO controls. Agreement shows consistency, not correctness. Nothing here changes a
|
|
decision until you accept or reject one.</p>
|
|
<div className="client-info-tabs mapping-review__filters" role="group" aria-label="Consistency state">
|
|
{FILTERS.map(option => {
|
|
const amount = review.items.filter(item => item.state === option.state).length;
|
|
return <button key={option.state} type="button" aria-pressed={filter === option.state}
|
|
onClick={() => setFilter(option.state)}>{option.label} ({number(amount)})</button>;
|
|
})}
|
|
</div>
|
|
{review.counts.notCompared > 0 && <p className="mapping-review__note">{number(review.counts.notCompared)} {review.counts.notCompared === 1 ? 'rule has' : 'rules have'} an
|
|
accepted ISO control but no accepted BSI requirement yet. Start the BSI mapping batch in the Result DB to compare them.</p>}
|
|
{!items.length ? <p className="iso-controls__empty">No rule in this state.</p>
|
|
: <div className="mapping-review__items">{items.map(item => <ReviewItem key={item.resultProjectionId} item={item} projectId={projectId}
|
|
backendStatus={backendStatus} onChanged={onChanged}/>)}</div>}
|
|
<p className="bsi-requirements__source">BSI texts: BSI Stand-der-Technik-Bibliothek, catalog {review.catalogVersion.slice(0, 10)}, CC BY-SA 4.0.</p>
|
|
</div>;
|
|
}
|
|
|
|
function ReviewItem({ item, projectId, backendStatus, onChanged }: {
|
|
item: RegulatoryConsistencyItem; projectId: string; backendStatus: BackendStatus; onChanged(): void;
|
|
}) {
|
|
const [open, setOpen] = useState(false);
|
|
return <details className="mapping-review__item" data-consistency={item.state} open={open} onToggle={event => setOpen(event.currentTarget.open)}>
|
|
<summary>
|
|
<span className="mapping-review__state">{CONSISTENCY_LABELS[item.state]}{item.reviewed ? ' · reviewed' : ''}</span>
|
|
<span className="mapping-review__title">{item.title}</span>
|
|
<span className="mapping-review__meta">{item.sourceTool}{item.sourceRuleId ? ` · ${item.sourceRuleId}` : ''} · {number(item.findingCount)} {item.findingCount === 1 ? 'finding' : 'findings'}</span>
|
|
</summary>
|
|
{/* The decision hooks load only for an opened rule. */}
|
|
{open && <ReviewDetail item={item} projectId={projectId} backendStatus={backendStatus} onChanged={onChanged}/>}
|
|
</details>;
|
|
}
|
|
|
|
function ReviewDetail({ item, projectId, backendStatus, onChanged }: {
|
|
item: RegulatoryConsistencyItem; projectId: string; backendStatus: BackendStatus; onChanged(): void;
|
|
}) {
|
|
const iso = useRegulatoryMapping(projectId, item.resultProjectionId, backendStatus);
|
|
const bsi = useRegulatoryMapping(projectId, item.resultProjectionId, backendStatus, 'bsi-kernel');
|
|
const [comment, setComment] = useState('');
|
|
const [saving, setSaving] = useState(false);
|
|
const [error, setError] = useState('');
|
|
const busy = saving || iso.loading || bsi.loading;
|
|
const markReviewed = async () => {
|
|
setSaving(true);
|
|
try {
|
|
await assessorApi.markRegulatoryMappingConsistencyReviewed(projectId, { resultProjectionId: item.resultProjectionId, ...(comment.trim() ? { comment: comment.trim() } : {}) });
|
|
setError('');
|
|
onChanged();
|
|
} catch (reason) {
|
|
setError(reason instanceof Error ? reason.message : 'The review could not be saved.');
|
|
} finally { setSaving(false); }
|
|
};
|
|
// A saved decision refreshes the tab overview and this list through the shared decision revision.
|
|
return <div className="mapping-review__detail">
|
|
<div className="mapping-review__side" aria-label="Accepted ISO controls">
|
|
<h4>Accepted ISO controls · {number(item.derivedCount)} derived BSI {item.derivedCount === 1 ? 'requirement' : 'requirements'}</h4>
|
|
{item.iso.length ? <ul>{item.iso.map(control => <li key={control.controlId}>
|
|
<strong>A.{control.controlId}</strong><span>{control.title ?? ''}</span>
|
|
<button className="btn" type="button" disabled={busy || !iso.response} onClick={() => void iso.decide(control, 'rejected', 'Rejected in the ISO ↔ BSI review.')}>Reject</button>
|
|
</li>)}</ul> : <p>No accepted ISO control.</p>}
|
|
</div>
|
|
<div className="mapping-review__side" aria-label="Directly accepted BSI requirements">
|
|
<h4>Mapped directly to BSI</h4>
|
|
<ul>{item.direct.map(requirement => <li key={requirement.requirementId}>
|
|
<strong>{requirement.requirementId}</strong><span>{requirement.title ?? ''}</span>
|
|
<span className="mapping-review__meta">{requirement.derived ? 'also derived from ISO' : requirement.reachable ? 'not derived from the accepted ISO controls' : 'no ISO control maps here'}</span>
|
|
<button className="btn" type="button" disabled={busy || !bsi.response} onClick={() => void bsi.decide({ controlId: requirement.requirementId }, 'rejected', 'Rejected in the ISO ↔ BSI review.')}>Reject</button>
|
|
</li>)}</ul>
|
|
</div>
|
|
{item.reverse.length > 0 && <div className="mapping-review__side" aria-label="ISO controls suggested by the BSI mapping">
|
|
<h4>ISO controls the BSI mapping points to</h4>
|
|
<ul>{item.reverse.map(control => <li key={control.controlId}>
|
|
<strong>A.{control.controlId}</strong><span>{control.title ?? ''}</span>
|
|
<span className="mapping-review__meta">via {control.requirementIds.join(', ')}</span>
|
|
{control.decidable
|
|
? <button className="btn" type="button" disabled={busy || !iso.response} onClick={() => void iso.decide(control, 'accepted', 'Accepted in the ISO ↔ BSI review.')}>Accept</button>
|
|
: <span className="mapping-review__meta">not retrieved by the ISO mapping</span>}
|
|
</li>)}</ul>
|
|
</div>}
|
|
{(iso.error || bsi.error || error) && <p className="regulatory-mapping-error" role="alert">{error || iso.error || bsi.error}</p>}
|
|
{item.state !== 'consistent' && !item.reviewed && <div className="mapping-review__actions">
|
|
<label>Review comment (optional)<input value={comment} maxLength={1000} onChange={event => setComment(event.target.value)}/></label>
|
|
<button className="btn" type="button" disabled={busy} onClick={() => void markReviewed()}>Mark reviewed</button>
|
|
</div>}
|
|
</div>;
|
|
}
|