F3.1 WEB: gestionar merge desde el expediente
This commit is contained in:
@@ -1,12 +1,32 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
|
import { useAuth } from '../../auth/AuthContext';
|
||||||
import { Alert, EmptyState, LoadingBlock, errorMessage } from '../../components/Feedback';
|
import { Alert, EmptyState, LoadingBlock, errorMessage } from '../../components/Feedback';
|
||||||
import { Icon } from '../../components/Icon';
|
import { Icon } from '../../components/Icon';
|
||||||
import { formatDate, formatDateOnly } from '../../lib/format';
|
import { formatDate, formatDateOnly } from '../../lib/format';
|
||||||
import { getAssetDossier } from '../../lib/api';
|
import { getAsset, getAssetDossier } from '../../lib/api';
|
||||||
import type { AssetDossier, AssetDossierTimelineEvent } 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 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) {
|
function findingStatusLabel(status: string) {
|
||||||
if (status === 'OPEN') return 'Abierto';
|
if (status === 'OPEN') return 'Abierto';
|
||||||
@@ -28,48 +48,118 @@ function actStatusLabel(status: string) {
|
|||||||
return labels[status] ?? status;
|
return labels[status] ?? status;
|
||||||
}
|
}
|
||||||
|
|
||||||
function timelineLabel(event: AssetDossierTimelineEvent) {
|
function timelineLabel(event: ExtendedTimelineEvent) {
|
||||||
const labels: Record<AssetDossierTimelineEvent['kind'], string> = {
|
const labels: Record<string, string> = {
|
||||||
INVENTORY_CHANGE: 'Inventario', INSPECTION: 'Inspección', ACT: 'Acta', FINDING: 'Hallazgo', FINDING_CLOSED: 'Cierre',
|
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 === 'FINDING' || event.kind === 'FINDING_CLOSED') return 'alert' as const;
|
||||||
if (event.kind === 'INSPECTION' || event.kind === 'VERIFICATION') return 'calendar' 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;
|
return 'clipboard' as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AssetDossierPanel({ assetId }: { assetId: string }) {
|
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 [view, setView] = useState<View>('timeline');
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
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);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
getAssetDossier(assetId)
|
Promise.all([
|
||||||
.then(setDossier)
|
getAssetDossier(assetId),
|
||||||
|
getAsset(assetId),
|
||||||
|
getInventoryMergeStatus(assetId),
|
||||||
|
])
|
||||||
|
.then(([loadedDossier, loadedAsset, loadedMerge]) => {
|
||||||
|
setDossier(loadedDossier as ExtendedDossier);
|
||||||
|
setAsset(loadedAsset);
|
||||||
|
setMergeStatus(loadedMerge);
|
||||||
|
})
|
||||||
.catch((requestError) => setError(errorMessage(requestError)))
|
.catch((requestError) => setError(errorMessage(requestError)))
|
||||||
.finally(() => setLoading(false));
|
.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 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 (loading) return <div className="panel"><LoadingBlock label="Armando expediente técnico…" /></div>;
|
||||||
if (!dossier) return <Alert>{error || 'No se pudo cargar el expediente.'}</Alert>;
|
if (!dossier) return <Alert>{error || 'No se pudo cargar el expediente.'}</Alert>;
|
||||||
|
|
||||||
return <div className="asset-dossier-stack">
|
return <div className="asset-dossier-stack">
|
||||||
{error && <Alert>{error}</Alert>}
|
{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">
|
<article className="panel dossier-overview">
|
||||||
<div className="panel-heading">
|
<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><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>
|
</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 className="dossier-metrics">
|
||||||
<div><small>Inspecciones</small><strong>{dossier.counters.inspections}</strong></div>
|
<div><small>Inspecciones</small><strong>{dossier.counters.inspections}</strong></div>
|
||||||
<div><small>Actas</small><strong>{dossier.counters.acts}</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>}
|
{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>
|
</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">
|
<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 === '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 === 'findings' ? 'active' : ''} onClick={() => setView('findings')}>Inspecciones y hallazgos</button>
|
||||||
@@ -91,14 +195,18 @@ export function AssetDossierPanel({ assetId }: { assetId: string }) {
|
|||||||
{view === 'timeline' && <article className="panel">
|
{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>
|
<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.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}>
|
{dossier.timeline.map((event) => {
|
||||||
<span className="dossier-timeline-icon"><Icon name={timelineIcon(event)} size={16} /></span>
|
const historicalInventory = (event.meta?.historicalInventory ?? null) as { id?: string; code?: string; name?: string; isCanonical?: boolean } | null;
|
||||||
<div className="dossier-timeline-body">
|
return <div className="dossier-timeline-item" key={event.id}>
|
||||||
<div className="dossier-timeline-head"><span>{timelineLabel(event)}</span><time>{formatDate(event.occurredAt)}</time></div>
|
<span className="dossier-timeline-icon"><Icon name={timelineIcon(event)} size={16} /></span>
|
||||||
{event.href ? <Link to={event.href}><strong>{event.title}</strong></Link> : <strong>{event.title}</strong>}
|
<div className="dossier-timeline-body">
|
||||||
{event.description && <p>{event.description}</p>}
|
<div className="dossier-timeline-head"><span>{timelineLabel(event)}</span><time>{formatDate(event.occurredAt)}</time></div>
|
||||||
</div>
|
{event.href ? <Link to={event.href}><strong>{event.title}</strong></Link> : <strong>{event.title}</strong>}
|
||||||
</div>)}
|
{event.description && <p>{event.description}</p>}
|
||||||
|
{historicalInventory && !historicalInventory.isCanonical && <small className="block-muted">Registrado originalmente en {historicalInventory.code} · {historicalInventory.name}</small>}
|
||||||
|
</div>
|
||||||
|
</div>;
|
||||||
|
})}
|
||||||
</div>}
|
</div>}
|
||||||
</article>}
|
</article>}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user