F3.1 WEB: administrar merge cronológico de Hallazgos de catálogo

This commit is contained in:
2026-09-07 15:15:58 -03:00
parent 810ac860c7
commit e54fa4503a
@@ -1,10 +1,11 @@
import { SearchableSelect } from '../../components/SearchableSelect';
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router';
import { Alert, EmptyState, LoadingBlock, errorMessage } from '../../components/Feedback';
import { Icon } from '../../components/Icon';
import { listFindingCatalogProposals, reviewFindingCatalogProposal } from '../../lib/api';
import type { FindingAdminCatalog, FindingCatalogProposal } from '../../lib/api';
import { mergeFindingCatalogItem } from '../../lib/findingCatalogMerge';
export function FindingCatalogProposalsPanel({ catalog }: { catalog: FindingAdminCatalog }) {
const [items, setItems] = useState<FindingCatalogProposal[]>([]);
@@ -14,6 +15,20 @@ export function FindingCatalogProposalsPanel({ catalog }: { catalog: FindingAdmi
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [mergeSourceId, setMergeSourceId] = useState('');
const [mergeCanonicalId, setMergeCanonicalId] = useState('');
const [mergeReason, setMergeReason] = useState('');
const [mergeBusy, setMergeBusy] = useState(false);
const [mergeError, setMergeError] = useState('');
const [mergeSuccess, setMergeSuccess] = useState('');
const activeCatalogItems = useMemo(
() => catalog.items.filter((item) => item.isActive && item.categoryActive),
[catalog.items],
);
const mergeSource = catalog.items.find((item) => item.id === mergeSourceId) ?? null;
const mergeCanonical = catalog.items.find((item) => item.id === mergeCanonicalId) ?? null;
const load = () => listFindingCatalogProposals({ status: 'PENDING' }).then(setItems);
useEffect(() => { load().catch((requestError) => setError(errorMessage(requestError))).finally(() => setLoading(false)); }, []);
@@ -38,11 +53,86 @@ export function FindingCatalogProposalsPanel({ catalog }: { catalog: FindingAdmi
}
};
if (loading) return <div className="panel"><LoadingBlock label="Cargando propuestas OTROS…" /></div>;
const mergeCatalog = async () => {
setMergeError(''); setMergeSuccess('');
if (!mergeSourceId || !mergeCanonicalId) {
setMergeError('Seleccioná el registro duplicado y el registro canónico.');
return;
}
if (mergeSourceId === mergeCanonicalId) {
setMergeError('El duplicado y el canónico deben ser registros distintos.');
return;
}
if (mergeReason.trim().length < 8) {
setMergeError('Indicá un motivo de al menos 8 caracteres para que la conciliación quede auditada.');
return;
}
const sourceLabel = mergeSource ? `${mergeSource.title} (${mergeSource.code})` : 'el duplicado';
const canonicalLabel = mergeCanonical ? `${mergeCanonical.title} (${mergeCanonical.code})` : 'el canónico';
if (!window.confirm(`¿Fusionar ${sourceLabel} dentro de ${canonicalLabel}?\n\nLos Hallazgos históricos conservarán su referencia original. El duplicado dejará de ofrecerse para inspecciones futuras.`)) return;
return <section className="panel finding-proposals-panel">
setMergeBusy(true);
try {
await mergeFindingCatalogItem(mergeSourceId, {
canonicalItemId: mergeCanonicalId,
reason: mergeReason.trim(),
});
setMergeSuccess('Fusión registrada. El historial emitido quedó intacto y la aplicabilidad futura pasó al registro canónico.');
setMergeSourceId('');
setMergeCanonicalId('');
setMergeReason('');
window.setTimeout(() => window.location.reload(), 900);
} catch (requestError) {
setMergeError(errorMessage(requestError));
} finally {
setMergeBusy(false);
}
};
return <>
<section className="panel finding-proposals-panel">
<div className="panel-heading"><div><span className="eyebrow">PROPUESTAS DESDE CAMPO</span><h2>Hallazgos cargados como OTROS</h2><p className="section-copy">El hallazgo original queda intacto. Esta bandeja sirve para decidir si conviene sumar esa opción al catálogo para inspecciones futuras.</p></div><span className="count-pill">{items.length} pendientes</span></div>
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
{items.length === 0 ? <EmptyState title="Sin propuestas pendientes" text="Los nuevos hallazgos cargados como OTROS aparecerán acá." /> : <div className="finding-proposal-list">{items.map((proposal) => <article className="finding-proposal-card" key={proposal.id}><div><span className="eyebrow">{proposal.assetTypeName} · {proposal.assetCode}</span><h3>{proposal.proposedTitle}</h3><p>{proposal.description}</p><small><Link to={`/hallazgos/${proposal.findingId}`}>{proposal.findingCode}</Link> · {proposal.assetName}{proposal.proposedSeverity ? ` · gravedad ${proposal.proposedSeverity}/10` : ''}</small></div><div className="finding-proposal-actions"><label className="field"><span>Coincide con</span><SearchableSelect value={matches[proposal.id] ?? ''} onChange={(event) => setMatches((current) => ({ ...current, [proposal.id]: event.target.value }))}><option value="">Seleccionar tipo existente</option>{catalog.items.filter((item) => item.isActive && item.categoryActive).map((item) => <option value={item.id} key={item.id}>{item.title} · {item.code}</option>)}</SearchableSelect></label><div className="form-actions"><button type="button" className="button secondary" disabled={busyId === proposal.id} onClick={() => review(proposal, 'REJECT')}>Descartar</button><button type="button" className="button primary" disabled={busyId === proposal.id || !matches[proposal.id]} onClick={() => review(proposal, 'MATCH')}><Icon name="check" />Vincular</button></div><small>Si todavía no existe una opción adecuada, creala en el catálogo y luego vinculá esta propuesta.</small></div></article>)}</div>}
</section>;
{loading ? <LoadingBlock label="Cargando propuestas OTROS…" /> : items.length === 0 ? <EmptyState title="Sin propuestas pendientes" text="Los nuevos hallazgos cargados como OTROS aparecerán acá." /> : <div className="finding-proposal-list">{items.map((proposal) => <article className="finding-proposal-card" key={proposal.id}><div><span className="eyebrow">{proposal.assetTypeName} · {proposal.assetCode}</span><h3>{proposal.proposedTitle}</h3><p>{proposal.description}</p><small><Link to={`/hallazgos/${proposal.findingId}`}>{proposal.findingCode}</Link> · {proposal.assetName}{proposal.proposedSeverity ? ` · gravedad ${proposal.proposedSeverity}/10` : ''}</small></div><div className="finding-proposal-actions"><label className="field"><span>Coincide con</span><SearchableSelect value={matches[proposal.id] ?? ''} onChange={(event) => setMatches((current) => ({ ...current, [proposal.id]: event.target.value }))}><option value="">Seleccionar tipo existente</option>{activeCatalogItems.map((item) => <option value={item.id} key={item.id}>{item.title} · {item.code}</option>)}</SearchableSelect></label><div className="form-actions"><button type="button" className="button secondary" disabled={busyId === proposal.id} onClick={() => review(proposal, 'REJECT')}>Descartar</button><button type="button" className="button primary" disabled={busyId === proposal.id || !matches[proposal.id]} onClick={() => review(proposal, 'MATCH')}><Icon name="check" />Vincular</button></div><small>Si todavía no existe una opción adecuada, creala en el catálogo y luego vinculá esta propuesta.</small></div></article>)}</div>}
</section>
<section className="panel finding-proposals-panel">
<div className="panel-heading">
<div>
<span className="eyebrow">CONCILIACIÓN DE CATÁLOGO</span>
<h2>Fusionar Hallazgos duplicados</h2>
<p className="section-copy">Usá esta operación cuando dos opciones del catálogo representan el mismo control. El duplicado se desactiva para el futuro, pero los Hallazgos y Actas ya emitidos conservan exactamente la referencia con la que nacieron.</p>
</div>
<span className="count-pill">append-only</span>
</div>
{mergeError && <Alert>{mergeError}</Alert>}{mergeSuccess && <Alert type="success">{mergeSuccess}</Alert>}
<div className="form-grid">
<label className="field">
<span>Duplicado a retirar</span>
<SearchableSelect value={mergeSourceId} onChange={(event) => { setMergeSourceId(event.target.value); if (event.target.value === mergeCanonicalId) setMergeCanonicalId(''); }}>
<option value="">Seleccionar duplicado</option>
{activeCatalogItems.map((item) => <option key={item.id} value={item.id}>{item.categoryName} · {item.title} · {item.code}</option>)}
</SearchableSelect>
{mergeSource && <small>Uso histórico: {mergeSource.usageCount} hallazgo{mergeSource.usageCount === 1 ? '' : 's'} · revisión {mergeSource.revision}</small>}
</label>
<label className="field">
<span>Registro canónico que continuará vigente</span>
<SearchableSelect value={mergeCanonicalId} onChange={(event) => setMergeCanonicalId(event.target.value)} disabled={!mergeSourceId}>
<option value="">Seleccionar canónico</option>
{activeCatalogItems.filter((item) => item.id !== mergeSourceId).map((item) => <option key={item.id} value={item.id}>{item.categoryName} · {item.title} · {item.code}</option>)}
</SearchableSelect>
{mergeCanonical && <small>Uso histórico: {mergeCanonical.usageCount} hallazgo{mergeCanonical.usageCount === 1 ? '' : 's'} · revisión {mergeCanonical.revision}</small>}
</label>
</div>
<label className="field">
<span>Motivo de la fusión</span>
<textarea rows={3} maxLength={2000} value={mergeReason} onChange={(event) => setMergeReason(event.target.value)} placeholder="Ej.: duplicado detectado al normalizar el catálogo del Apéndice 26…" />
<small>Obligatorio. Queda guardado en el evento de conciliación y no puede modificarse después.</small>
</label>
<div className="catalog-policy-note"><Icon name="shield" /><span><strong>Política histórica.</strong> Esta acción no reescribe inspection_findings ni propuestas ya resueltas. Sólo consolida la aplicabilidad para inspecciones futuras.</span></div>
<div className="form-actions">
<button type="button" className="button primary" disabled={mergeBusy || !mergeSourceId || !mergeCanonicalId || mergeReason.trim().length < 8} onClick={mergeCatalog}><Icon name="check" />{mergeBusy ? 'Fusionando…' : 'Fusionar y conservar historial'}</button>
</div>
</section>
</>;
}