F3.1 WEB: gestionar merge desde el expediente

This commit is contained in:
2026-09-07 14:23:37 -03:00
parent 636431f936
commit bd71e74df6
+129 -21
View File
@@ -1,12 +1,32 @@
import { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router';
import { useAuth } from '../../auth/AuthContext';
import { Alert, EmptyState, LoadingBlock, errorMessage } from '../../components/Feedback';
import { Icon } from '../../components/Icon';
import { formatDate, formatDateOnly } from '../../lib/format';
import { getAssetDossier } from '../../lib/api';
import type { AssetDossier, AssetDossierTimelineEvent } from '../../lib/api';
import { getAsset, getAssetDossier } from '../../lib/api';
import type { AssetDetail, AssetDossier, AssetDossierTimelineEvent, AssetListItem } from '../../lib/api';
import {
getInventoryMergeStatus,
mergeInventoryAsset,
searchInventoryMergeCandidates,
} from '../../lib/inventoryMergeApi';
import type { InventoryMergeStatus } from '../../lib/inventoryMergeApi';
type View = 'timeline' | 'findings' | 'documents';
type ExtendedTimelineEvent = Omit<AssetDossierTimelineEvent, 'kind'> & {
kind: AssetDossierTimelineEvent['kind'] | 'MERGE';
};
type ExtendedDossier = Omit<AssetDossier, 'timeline'> & {
timeline: ExtendedTimelineEvent[];
requestedAsset?: { id: string; code: string; name: string };
merge?: {
requestedWasMerged: boolean;
canonicalAssetId: string;
aliases: Array<{ id: string; code: string; name: string; reason: string; mergedAt: string; depth: number }>;
historyPolicy: string;
};
};
function findingStatusLabel(status: string) {
if (status === 'OPEN') return 'Abierto';
@@ -28,48 +48,118 @@ function actStatusLabel(status: string) {
return labels[status] ?? status;
}
function timelineLabel(event: AssetDossierTimelineEvent) {
const labels: Record<AssetDossierTimelineEvent['kind'], string> = {
function timelineLabel(event: ExtendedTimelineEvent) {
const labels: Record<string, string> = {
INVENTORY_CHANGE: 'Inventario', INSPECTION: 'Inspección', ACT: 'Acta', FINDING: 'Hallazgo', FINDING_CLOSED: 'Cierre',
COMMUNICATION: 'Seguimiento', PHOTO: 'Fotografía', DOCUMENT: 'Documento', REPORT: 'Informe', SOURCE_DOCUMENT: 'Documento fuente', VERIFICATION: 'Verificación',
COMMUNICATION: 'Seguimiento', PHOTO: 'Fotografía', DOCUMENT: 'Documento', REPORT: 'Informe', SOURCE_DOCUMENT: 'Documento fuente', VERIFICATION: 'Verificación', MERGE: 'Fusión',
};
return labels[event.kind];
return labels[event.kind] ?? event.kind;
}
function timelineIcon(event: AssetDossierTimelineEvent) {
function timelineIcon(event: ExtendedTimelineEvent) {
if (event.kind === 'FINDING' || event.kind === 'FINDING_CLOSED') return 'alert' as const;
if (event.kind === 'INSPECTION' || event.kind === 'VERIFICATION') return 'calendar' as const;
if (event.kind === 'INVENTORY_CHANGE') return 'history' as const;
if (event.kind === 'INVENTORY_CHANGE' || event.kind === 'MERGE') return 'history' as const;
return 'clipboard' as const;
}
export function AssetDossierPanel({ assetId }: { assetId: string }) {
const [dossier, setDossier] = useState<AssetDossier | null>(null);
const { hasPermission } = useAuth();
const [dossier, setDossier] = useState<ExtendedDossier | null>(null);
const [asset, setAsset] = useState<AssetDetail | null>(null);
const [mergeStatus, setMergeStatus] = useState<InventoryMergeStatus | null>(null);
const [view, setView] = useState<View>('timeline');
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [showMerge, setShowMerge] = useState(false);
const [mergeSearch, setMergeSearch] = useState('');
const [candidates, setCandidates] = useState<AssetListItem[]>([]);
const [canonicalId, setCanonicalId] = useState('');
const [mergeReason, setMergeReason] = useState('');
const [merging, setMerging] = useState(false);
useEffect(() => {
const load = () => {
setLoading(true);
setError('');
getAssetDossier(assetId)
.then(setDossier)
Promise.all([
getAssetDossier(assetId),
getAsset(assetId),
getInventoryMergeStatus(assetId),
])
.then(([loadedDossier, loadedAsset, loadedMerge]) => {
setDossier(loadedDossier as ExtendedDossier);
setAsset(loadedAsset);
setMergeStatus(loadedMerge);
})
.catch((requestError) => setError(errorMessage(requestError)))
.finally(() => setLoading(false));
}, [assetId]);
};
useEffect(() => { load(); }, [assetId]);
const mergeable = Boolean(
asset &&
['instalacion', 'subinstalacion'].includes(asset.type.code.toLowerCase()) &&
!mergeStatus?.isMerged &&
hasPermission('assets.update'),
);
useEffect(() => {
if (!showMerge || !mergeable || !asset) {
setCandidates([]);
return;
}
const timer = window.setTimeout(() => {
searchInventoryMergeCandidates(asset, mergeSearch)
.then(setCandidates)
.catch((requestError) => setError(errorMessage(requestError)));
}, 220);
return () => window.clearTimeout(timer);
}, [showMerge, mergeable, asset, mergeSearch]);
const pendingVerification = useMemo(() => dossier?.findings.filter((finding) => finding.status === 'OPEN' && finding.companyResponseReceivedOn && finding.nextControlOn).length ?? 0, [dossier]);
const executeMerge = async () => {
if (!asset || !canonicalId || mergeReason.trim().length < 8) return;
const target = candidates.find((candidate) => candidate.id === canonicalId);
if (!target) return;
const accepted = window.confirm(
`¿Fusionar ${asset.code} · ${asset.name} en ${target.code} · ${target.name}?\n\n` +
'El registro actual quedará inactivo y trazable. Las referencias históricas NO se reescriben. Esta fusión queda registrada como evento cronológico permanente.',
);
if (!accepted) return;
setMerging(true);
setError('');
try {
const result = await mergeInventoryAsset(asset.id, canonicalId, mergeReason.trim());
window.location.assign(`/inventarios/${result.canonical.id}?tab=dossier`);
} catch (requestError) {
setError(errorMessage(requestError));
setMerging(false);
}
};
if (loading) return <div className="panel"><LoadingBlock label="Armando expediente técnico…" /></div>;
if (!dossier) return <Alert>{error || 'No se pudo cargar el expediente.'}</Alert>;
return <div className="asset-dossier-stack">
{error && <Alert>{error}</Alert>}
{mergeStatus?.isMerged && <article className="panel">
<div className="temporal-notice">
<Icon name="history" />
<p><strong>Registro fusionado.</strong> {mergeStatus.requested.code} · {mergeStatus.requested.name} conserva su historia, pero el registro vigente es <Link to={`/inventarios/${mergeStatus.canonical.id}?tab=dossier`}><strong>{mergeStatus.canonical.code} · {mergeStatus.canonical.name}</strong></Link>.</p>
</div>
</article>}
<article className="panel dossier-overview">
<div className="panel-heading">
<div><span className="eyebrow">EXPEDIENTE TÉCNICO</span><h2>{dossier.asset.name}</h2><p className="section-copy">{dossier.asset.code}{dossier.asset.commonName ? ` · Nombre habitual: ${dossier.asset.commonName}` : ''} · Inspecciones, actas, hallazgos, respuestas, evidencias, documentos y cambios reunidos en un solo lugar.</p></div>
</div>
{dossier.merge && dossier.merge.aliases.length > 0 && <div className="temporal-notice">
<Icon name="history" />
<p><strong>Expediente cronológico unificado:</strong> incluye {dossier.merge.aliases.length} registro{dossier.merge.aliases.length === 1 ? '' : 's'} fusionado{dossier.merge.aliases.length === 1 ? '' : 's'}. Cada evento mantiene visible la identidad de Inventario que tenía al momento de ocurrir.</p>
</div>}
<div className="dossier-metrics">
<div><small>Inspecciones</small><strong>{dossier.counters.inspections}</strong></div>
<div><small>Actas</small><strong>{dossier.counters.acts}</strong></div>
@@ -82,6 +172,20 @@ export function AssetDossierPanel({ assetId }: { assetId: string }) {
{pendingVerification > 0 && <div className="temporal-notice"><Icon name="calendar" /><p><strong>{pendingVerification} hallazgo{pendingVerification === 1 ? '' : 's'} con verificación programada.</strong> Las fechas operativas pueden utilizarse para planificar próximos controles.</p></div>}
</article>
{mergeable && <article className="panel">
<div className="panel-heading">
<div><span className="eyebrow">CONCILIACIÓN</span><h2>¿Este registro está duplicado?</h2><p className="section-copy">Fusioná únicamente cuando ambas fichas representen la misma {asset?.type.name.toLowerCase()}. La historia anterior se conserva.</p></div>
<button type="button" className="button secondary" onClick={() => setShowMerge((current) => !current)}><Icon name="history" />{showMerge ? 'Cancelar fusión' : 'Fusionar duplicado'}</button>
</div>
{showMerge && <div className="form-section">
<label className="field"><span>Buscar registro canónico</span><input value={mergeSearch} onChange={(event) => { setMergeSearch(event.target.value); setCanonicalId(''); }} placeholder="Nombre o código…" /></label>
<label className="field"><span>Conservar como registro oficial</span><select value={canonicalId} onChange={(event) => setCanonicalId(event.target.value)}><option value="">Seleccionar registro</option>{candidates.map((candidate) => <option key={candidate.id} value={candidate.id}>{candidate.code} · {candidate.name}</option>)}</select><small>Se muestran registros activos del mismo tipo, padre, Área y Operadora.</small></label>
<label className="field"><span>Motivo de la fusión <em>obligatorio</em></span><textarea rows={3} minLength={8} maxLength={2000} value={mergeReason} onChange={(event) => setMergeReason(event.target.value)} placeholder="Ej.: alta de campo duplicada; se confirmó que corresponde a la instalación existente…" /></label>
<Alert type="info">La fusión no cambia Actas ni Hallazgos históricos. Los hijos actuales se reubican al canónico con una nueva versión y el registro duplicado queda inactivo, nunca eliminado.</Alert>
<div className="form-actions"><button type="button" className="button primary" disabled={!canonicalId || mergeReason.trim().length < 8 || merging} onClick={executeMerge}>{merging ? 'Fusionando…' : 'Confirmar fusión cronológica'}</button></div>
</div>}
</article>}
<nav className="dossier-subtabs" aria-label="Vistas del expediente">
<button type="button" className={view === 'timeline' ? 'active' : ''} onClick={() => setView('timeline')}>Cronología</button>
<button type="button" className={view === 'findings' ? 'active' : ''} onClick={() => setView('findings')}>Inspecciones y hallazgos</button>
@@ -91,14 +195,18 @@ export function AssetDossierPanel({ assetId }: { assetId: string }) {
{view === 'timeline' && <article className="panel">
<div className="panel-heading"><div><span className="eyebrow">CRONOLOGÍA</span><h2>Línea de tiempo</h2><p className="section-copy">Cada evento conserva su origen y enlaza con el registro que lo generó cuando corresponde.</p></div><span className="count-pill">{dossier.timeline.length}</span></div>
{dossier.timeline.length === 0 ? <EmptyState title="Sin actividad histórica" text="Los eventos aparecerán a medida que el elemento sea inspeccionado o actualizado." /> : <div className="dossier-timeline">
{dossier.timeline.map((event) => <div className="dossier-timeline-item" key={event.id}>
<span className="dossier-timeline-icon"><Icon name={timelineIcon(event)} size={16} /></span>
<div className="dossier-timeline-body">
<div className="dossier-timeline-head"><span>{timelineLabel(event)}</span><time>{formatDate(event.occurredAt)}</time></div>
{event.href ? <Link to={event.href}><strong>{event.title}</strong></Link> : <strong>{event.title}</strong>}
{event.description && <p>{event.description}</p>}
</div>
</div>)}
{dossier.timeline.map((event) => {
const historicalInventory = (event.meta?.historicalInventory ?? null) as { id?: string; code?: string; name?: string; isCanonical?: boolean } | null;
return <div className="dossier-timeline-item" key={event.id}>
<span className="dossier-timeline-icon"><Icon name={timelineIcon(event)} size={16} /></span>
<div className="dossier-timeline-body">
<div className="dossier-timeline-head"><span>{timelineLabel(event)}</span><time>{formatDate(event.occurredAt)}</time></div>
{event.href ? <Link to={event.href}><strong>{event.title}</strong></Link> : <strong>{event.title}</strong>}
{event.description && <p>{event.description}</p>}
{historicalInventory && !historicalInventory.isCanonical && <small className="block-muted">Registrado originalmente en {historicalInventory.code} · {historicalInventory.name}</small>}
</div>
</div>;
})}
</div>}
</article>}