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

160 lines
8.1 KiB
TypeScript

import { SearchableSelect } from '../../components/SearchableSelect';
import { useEffect, useMemo, useState } from 'react';
import type { FormEvent } from 'react';
import { Alert, LoadingBlock, errorMessage } from '../../components/Feedback';
import { Icon } from '../../components/Icon';
import {
changeAssetContext,
listAssetContextHistory,
listAssetParentOptions,
listOperationalAreas,
} from '../../lib/api';
import type {
AssetContextHistoryItem,
AssetDetail,
AssetListItem,
AssetType,
OperationalAssetSummary,
} from '../../lib/api';
import { formatDate } from '../../lib/format';
function localDateTimeNow(): string {
const now = new Date();
const local = new Date(now.getTime() - now.getTimezoneOffset() * 60_000);
return local.toISOString().slice(0, 16);
}
function contextLabel(item: AssetContextHistoryItem) {
const parts = [
item.parent ? `Padre: ${item.parent.name}` : 'Sin padre',
item.operationalArea ? `Área: ${item.operationalArea.name}` : null,
].filter(Boolean);
return parts.join(' · ');
}
export function AssetContextHistoryPanel({
asset,
type,
canManage,
onChanged,
}: {
asset: AssetDetail;
type: AssetType;
canManage: boolean;
onChanged: (asset: AssetDetail) => void;
}) {
const [history, setHistory] = useState<AssetContextHistoryItem[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [editing, setEditing] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [parentId, setParentId] = useState(asset.parent?.id ?? '');
const [parentSearch, setParentSearch] = useState('');
const [parents, setParents] = useState<AssetListItem[]>([]);
const [operationalAreaId, setOperationalAreaId] = useState(asset.operationalArea?.id ?? '');
const [areas, setAreas] = useState<OperationalAssetSummary[]>([]);
const [effectiveAt, setEffectiveAt] = useState(localDateTimeNow());
const [reason, setReason] = useState('');
const genericContext = type.operationalRole === 'GENERIC';
const current = useMemo(() => history.find((item) => item.isCurrent) ?? history[0] ?? null, [history]);
const loadHistory = () => {
setLoading(true);
listAssetContextHistory(asset.id)
.then(setHistory)
.catch((requestError) => setError(errorMessage(requestError)))
.finally(() => setLoading(false));
};
useEffect(loadHistory, [asset.id]);
useEffect(() => {
if (!editing) return;
const timer = window.setTimeout(() => {
listAssetParentOptions(type.id, asset.id, parentSearch)
.then(setParents)
.catch((requestError) => setError(errorMessage(requestError)));
}, 180);
return () => window.clearTimeout(timer);
}, [editing, type.id, asset.id, parentSearch]);
useEffect(() => {
if (!editing || !genericContext || !parentId) {
setAreas([]);
return;
}
listOperationalAreas(parentId)
.then((items) => {
setAreas(items);
if (operationalAreaId && !items.some((item) => item.id === operationalAreaId)) {
setOperationalAreaId('');
}
})
.catch((requestError) => setError(errorMessage(requestError)));
}, [editing, genericContext, parentId]);
const beginEdit = () => {
setParentId(asset.parent?.id ?? '');
setOperationalAreaId(asset.operationalArea?.id ?? '');
setEffectiveAt(localDateTimeNow());
setReason('');
setError('');
setSuccess('');
setEditing(true);
};
const save = async (event: FormEvent) => {
event.preventDefault();
setSaving(true);
setError('');
setSuccess('');
try {
const saved = await changeAssetContext(asset.id, {
parentId: parentId || null,
operationalAreaId: genericContext ? operationalAreaId || null : asset.operationalArea?.id ?? null,
effectiveAt: effectiveAt ? new Date(effectiveAt).toISOString() : undefined,
reason,
});
onChanged(saved);
setEditing(false);
setSuccess('Contexto físico actualizado. La asignación anterior quedó preservada en el historial.');
loadHistory();
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setSaving(false);
}
};
return <article className="panel asset-history-panel">
<div className="panel-heading">
<div><span className="eyebrow">CONTEXTO TEMPORAL</span><h2>Jerarquía y Área</h2></div>
{canManage && !editing && <button type="button" className="button secondary" onClick={beginEdit}><Icon name="edit" />Cambiar contexto físico</button>}
</div>
<p className="section-copy">Los cambios físicos no reemplazan la historia. La Operadora se administra por separado en las relaciones temporales del Área.</p>
{error && <Alert>{error}</Alert>}
{success && <Alert type="success">{success}</Alert>}
{current && <div className="temporal-notice"><Icon name="layers" /><p><strong>Contexto físico vigente.</strong> {contextLabel(current)}</p></div>}
{editing && <form className="form-section" onSubmit={save}>
<div><h3>Cambiar contexto físico vigente</h3><p className="section-copy">Indicá la nueva ubicación dentro del Inventario y el motivo. La relación física anterior se cierra automáticamente.</p></div>
<div className="form-grid">
<div className="field parent-picker"><span>Registro padre</span><input className="parent-search" value={parentSearch} onChange={(event) => setParentSearch(event.target.value)} placeholder="Buscar registro padre…" /><SearchableSelect value={parentId} onChange={(event) => { setParentId(event.target.value); setParentSearch(''); }} required={!type.canBeRoot}><option value="">{type.canBeRoot ? 'Sin padre · registro raíz' : 'Seleccionar registro padre…'}</option>{parents.map((parent) => <option key={parent.id} value={parent.id}>{parent.name} · {parent.code} ({parent.type.name})</option>)}</SearchableSelect></div>
<label className="field"><span>Vigente desde</span><input type="datetime-local" value={effectiveAt} onChange={(event) => setEffectiveAt(event.target.value)} required /><small>Puede registrarse una vigencia pasada si corresponde a un cambio ya ocurrido.</small></label>
</div>
{genericContext && <div className="form-grid">
<label className="field"><span>Área</span><SearchableSelect value={operationalAreaId} onChange={(event) => setOperationalAreaId(event.target.value)} required><option value="">Seleccionar Área</option>{areas.map((area) => <option key={area.id} value={area.id}>{area.name}</option>)}</SearchableSelect><small>La Operadora vigente se resuelve desde la relación temporal del Área.</small></label>
</div>}
<label className="field"><span>Motivo del cambio</span><textarea value={reason} onChange={(event) => setReason(event.target.value)} minLength={5} maxLength={2000} rows={3} required placeholder="Ej.: corrección documental, reubicación física, ajuste de jerarquía…" /></label>
<div className="form-actions"><button type="button" className="button secondary" onClick={() => setEditing(false)} disabled={saving}>Cancelar</button><button className="button primary" disabled={saving || reason.trim().length < 5}><Icon name="check" />{saving ? 'Registrando…' : 'Registrar cambio físico'}</button></div>
</form>}
<div className="form-section"><div><h3>Historial de contexto físico</h3><p className="section-copy">Se muestra la secuencia completa de ubicaciones conocidas del elemento.</p></div>
{loading ? <LoadingBlock label="Cargando contexto…" /> : history.length === 0 ? <div className="inline-empty">Todavía no hay contexto histórico registrado.</div> : <div className="asset-timeline">{history.map((item) => <div className="timeline-entry" key={item.id}><span className={`timeline-dot ${item.isCurrent ? 'current' : ''}`} /><span><strong>{item.isCurrent ? 'Vigente' : 'Histórico'} · v{item.assetVersionNumber}</strong><small>{formatDate(item.validFrom)} {item.validUntil ? formatDate(item.validUntil) : 'actualidad'}{item.creator ? ` · ${item.creator.username}` : ''}</small><span>{contextLabel(item)}</span><small>{item.changeReason}</small></span></div>)}</div>}
</div>
</article>;
}