F3.1 WEB: gestionar merge desde el expediente
This commit is contained in:
@@ -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>}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user