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 { 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'; if (status === 'CLOSED') return 'Cerrado'; return 'Anulado'; } function inspectionStatusLabel(status: string) { const labels: Record = { DRAFT: 'Borrador', PLANNED: 'Planificada', IN_PROGRESS: 'En curso', CLOSED: 'Cerrada', CANCELLED: 'Cancelada', }; return labels[status] ?? status; } function actStatusLabel(status: string) { const labels: Record = { DRAFT: 'Borrador', READY: 'Lista', CLOSED: 'Cerrada', CANCELLED: 'Cancelada', RECTIFIED: 'Rectificada', }; return labels[status] ?? status; } 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', MERGE: 'Fusión', }; return labels[event.kind] ?? event.kind; } 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' || event.kind === 'MERGE') return 'history' as const; return 'clipboard' as const; } export function AssetDossierPanel({ assetId }: { assetId: string }) { 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); const load = () => { setLoading(true); setError(''); setMergeStatus(null); Promise.all([ getAssetDossier(assetId), getAsset(assetId), // La conciliación es auxiliar: nunca debe bloquear la Actividad del Inventario. getInventoryMergeStatus(assetId).catch(() => null), ]) .then(([loadedDossier, loadedAsset, loadedMerge]) => { setDossier(loadedDossier as ExtendedDossier); setAsset(loadedAsset); setMergeStatus(loadedMerge); }) .catch((requestError) => setError(errorMessage(requestError))) .finally(() => setLoading(false)); }; 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}
Hallazgos abiertos{dossier.counters.openFindings}
Hallazgos cerrados{dossier.counters.closedFindings}
Fotos{dossier.counters.photos}
Informes{dossier.counters.reports}
Verificaciones{dossier.counters.verifications}
{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 &&