Files
dh-inspeccion-v2/web-v2/src/features/assets/AssetDossierPanel.tsx
T

255 lines
18 KiB
TypeScript

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<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';
if (status === 'CLOSED') return 'Cerrado';
return 'Anulado';
}
function inspectionStatusLabel(status: string) {
const labels: Record<string, string> = {
DRAFT: 'Borrador', PLANNED: 'Planificada', IN_PROGRESS: 'En curso', CLOSED: 'Cerrada', CANCELLED: 'Cancelada',
};
return labels[status] ?? status;
}
function actStatusLabel(status: string) {
const labels: Record<string, string> = {
DRAFT: 'Borrador', READY: 'Lista', CLOSED: 'Cerrada', CANCELLED: 'Cancelada', RECTIFIED: 'Rectificada',
};
return labels[status] ?? status;
}
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', 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<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);
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 <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>
<div className={dossier.counters.openFindings ? 'attention' : ''}><small>Hallazgos abiertos</small><strong>{dossier.counters.openFindings}</strong></div>
<div><small>Hallazgos cerrados</small><strong>{dossier.counters.closedFindings}</strong></div>
<div><small>Fotos</small><strong>{dossier.counters.photos}</strong></div>
<div><small>Informes</small><strong>{dossier.counters.reports}</strong></div>
<div><small>Verificaciones</small><strong>{dossier.counters.verifications}</strong></div>
</div>
{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>
<button type="button" className={view === 'documents' ? 'active' : ''} onClick={() => setView('documents')}>Documentos y evidencias</button>
</nav>
{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) => {
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>}
{view === 'findings' && <div className="asset-dossier-grid">
<article className="panel">
<div className="panel-heading"><div><span className="eyebrow">INSPECCIONES</span><h2>Visitas relacionadas</h2></div><span className="count-pill">{dossier.visits.length}</span></div>
{dossier.visits.length === 0 ? <EmptyState title="Sin inspecciones" text="Todavía no hay visitas asociadas a este elemento." /> : <div className="dossier-link-list">{dossier.visits.map((visit) => <Link key={visit.id} to={`/inspecciones/${visit.id}`}><div><strong>{visit.code}</strong><small>{inspectionStatusLabel(visit.status)}</small></div><span>{formatDate(visit.actualStartedAt ?? visit.plannedStartAt ?? visit.createdAt)}</span><Icon name="chevron" size={15} /></Link>)}</div>}
</article>
<article className="panel">
<div className="panel-heading"><div><span className="eyebrow">ACTAS</span><h2>Actas relacionadas</h2></div><span className="count-pill">{dossier.acts.length}</span></div>
{dossier.acts.length === 0 ? <EmptyState title="Sin actas" text="Todavía no hay actas asociadas a este elemento." /> : <div className="dossier-link-list">{dossier.acts.map((act) => <Link key={act.id} to={`/inspecciones/actas/${act.id}`}><div><strong>{act.code}</strong><small>{act.title} · {actStatusLabel(act.status)}</small></div><span>{formatDate(act.occurredAt)}</span><Icon name="chevron" size={15} /></Link>)}</div>}
</article>
<article className="panel dossier-wide">
<div className="panel-heading"><div><span className="eyebrow">HALLAZGOS</span><h2>Seguimiento del elemento</h2></div><Link className="text-link" to="/hallazgos">Ver bandeja general <Icon name="chevron" size={14} /></Link></div>
{dossier.findings.length === 0 ? <EmptyState title="Sin hallazgos" text="Este elemento todavía no registra hallazgos." /> : <div className="table-scroll"><table><thead><tr><th>Hallazgo</th><th>Estado</th><th>Acta</th><th>Vencimiento empresa</th><th>Verificación</th><th /></tr></thead><tbody>{dossier.findings.map((finding) => <tr key={finding.id}><td><strong>{finding.title}</strong><small className="block-muted">{finding.code}</small></td><td><span className={`status-badge ${finding.status === 'OPEN' ? 'observed' : 'active'}`}>{findingStatusLabel(finding.status)}</span></td><td><Link className="text-link" to={`/inspecciones/actas/${finding.actId}`}>{finding.actCode}</Link></td><td>{formatDateOnly(finding.correctionDueOn)}</td><td>{formatDateOnly(finding.nextControlOn)}</td><td className="action-cell"><Link className="icon-button" to={`/hallazgos/${finding.id}`} aria-label={`Abrir ${finding.code}`}><Icon name="chevron" /></Link></td></tr>)}</tbody></table></div>}
</article>
</div>}
{view === 'documents' && <div className="asset-dossier-grid">
<article className="panel">
<div className="panel-heading"><div><span className="eyebrow">DOCUMENTOS</span><h2>Documentos vinculados</h2></div><span className="count-pill">{dossier.documents.length}</span></div>
{dossier.documents.length === 0 ? <EmptyState title="Sin documentos fuente" text="No hay documentos fuente vinculados directamente a este registro." /> : <div className="dossier-document-list">{dossier.documents.map((document) => <div key={document.id}><Icon name="clipboard" /><div><strong>{document.title}</strong><small>{document.documentNumber ?? document.documentType} · {formatDateOnly(document.documentDate)}</small></div></div>)}</div>}
</article>
<article className="panel">
<div className="panel-heading"><div><span className="eyebrow">EVIDENCIAS</span><h2>Fotos y archivos de hallazgos</h2></div><span className="count-pill">{dossier.evidence.length}</span></div>
{dossier.evidence.length === 0 ? <EmptyState title="Sin evidencias de inspección" text="Las fotos y documentos de hallazgos aparecerán aquí en orden histórico." /> : <div className="dossier-link-list">{dossier.evidence.map((item) => <Link key={item.id} to={`/hallazgos/${item.findingId}`}><div><strong>{item.title ?? item.originalName}</strong><small>{item.findingCode} · {item.kind === 'PHOTO' ? 'Fotografía' : 'Documento'}</small></div><span>{formatDate(item.capturedAt ?? item.createdAt)}</span><Icon name="chevron" size={15} /></Link>)}</div>}
</article>
<article className="panel dossier-wide">
<div className="panel-heading"><div><span className="eyebrow">INFORMES DE INSPECCIÓN</span><h2>Informes relacionados</h2><p className="section-copy">Informes emitidos desde inspecciones que incluyeron este elemento o un hallazgo asociado.</p></div><Link className="text-link" to="/informes">Ver centro de informes <Icon name="chevron" size={14} /></Link></div>
{dossier.inspectionReports.length === 0 ? <EmptyState title="Sin informes de inspección" text="Todavía no hay informes emitidos para este elemento." /> : <div className="dossier-link-list">{dossier.inspectionReports.map((report) => <Link key={report.id} to={`/informes/${report.id}`}><div><strong>{report.code}</strong><small>{report.title} · {report.pdfStatus === 'READY' ? 'PDF disponible' : 'PDF pendiente'}</small></div><span>{formatDate(report.generatedAt)}</span><Icon name="chevron" size={15} /></Link>)}</div>}
</article>
<article className="panel dossier-wide">
<div className="panel-heading"><div><span className="eyebrow">DOCUMENTACIÓN TÉCNICA FUENTE</span><h2>Informes técnicos vinculados</h2></div><span className="count-pill">{dossier.reports.length}</span></div>
{dossier.reports.length === 0 ? <EmptyState title="Sin informes técnicos fuente" text="No hay documentos fuente clasificados como informe técnico para este elemento." /> : <div className="dossier-document-list">{dossier.reports.map((report) => <div key={report.id}><Icon name="clipboard" /><div><strong>{report.title}</strong><small>{report.documentNumber ?? 'Informe técnico'} · {formatDateOnly(report.documentDate)}</small></div></div>)}</div>}
</article>
</div>}
</div>;
}