160 lines
8.1 KiB
TypeScript
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>;
|
|
}
|