From bd71e74df6fdfd8663f069b147858db0573c3a4d Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 14:23:37 -0300 Subject: [PATCH] F3.1 WEB: gestionar merge desde el expediente --- .../src/features/assets/AssetDossierPanel.tsx | 150 +++++++++++++++--- 1 file changed, 129 insertions(+), 21 deletions(-) diff --git a/web-v2/src/features/assets/AssetDossierPanel.tsx b/web-v2/src/features/assets/AssetDossierPanel.tsx index 86fb9f4..2ed5409 100644 --- a/web-v2/src/features/assets/AssetDossierPanel.tsx +++ b/web-v2/src/features/assets/AssetDossierPanel.tsx @@ -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 & { + kind: AssetDossierTimelineEvent['kind'] | 'MERGE'; +}; +type ExtendedDossier = Omit & { + 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 = { +function timelineLabel(event: ExtendedTimelineEvent) { + const labels: Record = { 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(null); + const { hasPermission } = useAuth(); + const [dossier, setDossier] = useState(null); + const [asset, setAsset] = useState(null); + const [mergeStatus, setMergeStatus] = useState(null); const [view, setView] = useState('timeline'); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); + const [showMerge, setShowMerge] = useState(false); + const [mergeSearch, setMergeSearch] = useState(''); + const [candidates, setCandidates] = useState([]); + 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
; if (!dossier) return {error || 'No se pudo cargar el expediente.'}; return
{error && {error}} + {mergeStatus?.isMerged &&
+
+ +

Registro fusionado. {mergeStatus.requested.code} · {mergeStatus.requested.name} conserva su historia, pero el registro vigente es {mergeStatus.canonical.code} · {mergeStatus.canonical.name}.

+
+
} +
EXPEDIENTE TÉCNICO

{dossier.asset.name}

{dossier.asset.code}{dossier.asset.commonName ? ` · Nombre habitual: ${dossier.asset.commonName}` : ''} · Inspecciones, actas, hallazgos, respuestas, evidencias, documentos y cambios reunidos en un solo lugar.

+ {dossier.merge && dossier.merge.aliases.length > 0 &&
+ +

Expediente cronológico unificado: 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.

+
}
Inspecciones{dossier.counters.inspections}
Actas{dossier.counters.acts}
@@ -82,6 +172,20 @@ export function AssetDossierPanel({ assetId }: { assetId: string }) { {pendingVerification > 0 &&

{pendingVerification} hallazgo{pendingVerification === 1 ? '' : 's'} con verificación programada. Las fechas operativas pueden utilizarse para planificar próximos controles.

}
+ {mergeable &&
+
+
CONCILIACIÓN

¿Este registro está duplicado?

Fusioná únicamente cuando ambas fichas representen la misma {asset?.type.name.toLowerCase()}. La historia anterior se conserva.

+ +
+ {showMerge &&
+ + +