F3.1 WEB: administrar merge cronológico de Hallazgos de catálogo
This commit is contained in:
@@ -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>
|
||||
</>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user