96 lines
7.5 KiB
TypeScript
96 lines
7.5 KiB
TypeScript
import { SearchableSelect } from '../components/SearchableSelect';
|
||
import { useEffect, useState } from 'react';
|
||
import type { FormEvent } from 'react';
|
||
import { Link } from 'react-router';
|
||
import { Alert, EmptyState, LoadingBlock, errorMessage } from '../components/Feedback';
|
||
import { Icon } from '../components/Icon';
|
||
import { AssetCenterTabs } from '../features/assets/AssetCenterTabs';
|
||
import { assetStatusLabel, ASSET_STATUSES } from '../features/assets/assetPresentation';
|
||
import { AssetVersionDrawer } from '../features/assets/AssetVersionDrawer';
|
||
import { assetVersionChangeLabel } from '../features/assets/assetVersionPresentation';
|
||
import { getTemporalAsset, listAssetTypes, listTemporalAssets } from '../lib/api';
|
||
import type {
|
||
AssetInformationStatus,
|
||
AssetType,
|
||
PageMeta,
|
||
TemporalAssetDetail,
|
||
TemporalAssetSummary,
|
||
} from '../lib/api';
|
||
import { formatDate } from '../lib/format';
|
||
|
||
function localDateTime(date: Date): string {
|
||
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000);
|
||
return local.toISOString().slice(0, 16);
|
||
}
|
||
|
||
interface TemporalFilters {
|
||
search: string;
|
||
typeId: string;
|
||
status: AssetInformationStatus | '';
|
||
}
|
||
|
||
const emptyFilters: TemporalFilters = { search: '', typeId: '', status: '' };
|
||
|
||
export function TemporalAssetsPage() {
|
||
const initialDate = new Date();
|
||
const [draftAt, setDraftAt] = useState(localDateTime(initialDate));
|
||
const [at, setAt] = useState(initialDate.toISOString());
|
||
const [draft, setDraft] = useState<TemporalFilters>(emptyFilters);
|
||
const [filters, setFilters] = useState<TemporalFilters>(emptyFilters);
|
||
const [assets, setAssets] = useState<TemporalAssetSummary[]>([]);
|
||
const [types, setTypes] = useState<AssetType[]>([]);
|
||
const [meta, setMeta] = useState<PageMeta>({ page: 1, pageSize: 25, total: 0, totalPages: 0 });
|
||
const [page, setPage] = useState(1);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState('');
|
||
const [detail, setDetail] = useState<TemporalAssetDetail | null>(null);
|
||
const [detailLoading, setDetailLoading] = useState(false);
|
||
|
||
useEffect(() => { listAssetTypes().then(setTypes).catch(() => undefined); }, []);
|
||
useEffect(() => {
|
||
setLoading(true); setError('');
|
||
listTemporalAssets({ at, page, pageSize: 25, ...filters })
|
||
.then((response) => { setAssets(response.data); setMeta(response.meta); })
|
||
.catch((requestError) => setError(errorMessage(requestError)))
|
||
.finally(() => setLoading(false));
|
||
}, [at, filters, page]);
|
||
|
||
const apply = (event: FormEvent) => {
|
||
event.preventDefault();
|
||
const selected = new Date(draftAt);
|
||
if (Number.isNaN(selected.getTime())) { setError('Seleccioná una fecha y hora válida.'); return; }
|
||
setPage(1);
|
||
setAt(selected.toISOString());
|
||
setFilters({ ...draft, search: draft.search.trim() });
|
||
};
|
||
const reset = () => {
|
||
const now = new Date();
|
||
setDraftAt(localDateTime(now)); setAt(now.toISOString());
|
||
setDraft(emptyFilters); setFilters(emptyFilters); setPage(1);
|
||
};
|
||
const open = async (asset: TemporalAssetSummary) => {
|
||
setDetail(null); setDetailLoading(true); setError('');
|
||
try { setDetail(await getTemporalAsset(asset.assetId, at)); }
|
||
catch (requestError) { setError(errorMessage(requestError)); }
|
||
finally { setDetailLoading(false); }
|
||
};
|
||
|
||
return <section>
|
||
<div className="page-heading"><div><span className="eyebrow">INVENTARIOS</span><h1>Registros</h1><p>Reconstruí cómo se encontraban los inventarios en una fecha y hora.</p></div><span className="count-pill large">{meta.total} registros</span></div>
|
||
<AssetCenterTabs active="temporal" />
|
||
<div className="temporal-notice"><Icon name="history" /><p><strong>Vista histórica exacta.</strong> Los resultados provienen de snapshots inmutables; no reemplazan ni modifican los datos actuales.</p></div>
|
||
<form className="history-filters panel temporal-filters" onSubmit={apply}>
|
||
<label className="field temporal-date"><span>Fecha y hora de consulta</span><input type="datetime-local" value={draftAt} onChange={(event) => setDraftAt(event.target.value)} required /></label>
|
||
<label className="search-field"><Icon name="search" /><input value={draft.search} onChange={(event) => setDraft((current) => ({ ...current, search: event.target.value }))} placeholder="Código o nombre histórico" /></label>
|
||
<label className="field compact-field"><span>Tipo</span><SearchableSelect value={draft.typeId} onChange={(event) => setDraft((current) => ({ ...current, typeId: event.target.value }))}><option value="">Todos</option>{types.map((type) => <option key={type.id} value={type.id}>{type.name}</option>)}</SearchableSelect></label>
|
||
<label className="field compact-field"><span>Estado</span><SearchableSelect value={draft.status} onChange={(event) => setDraft((current) => ({ ...current, status: event.target.value as AssetInformationStatus | '' }))}><option value="">Todos</option>{ASSET_STATUSES.map((status) => <option key={status.value} value={status.value}>{status.label}</option>)}</SearchableSelect></label>
|
||
<div className="filter-actions"><button className="button text" type="button" onClick={reset}>Volver al presente</button><button className="button primary"><Icon name="history" />Reconstruir</button></div>
|
||
</form>
|
||
|
||
<div className="temporal-result-heading"><strong>Inventarios reconstruidos al {formatDate(at)}</strong><span>Las vigencias terminan cuando se registra la versión siguiente.</span></div>
|
||
{error && <Alert>{error}</Alert>}
|
||
{loading ? <LoadingBlock label="Reconstruyendo inventarios…" /> : assets.length === 0 ? <EmptyState title="Sin registros en esa fecha" text="No existen versiones históricas que coincidan con la fecha y los filtros seleccionados." /> : <div className="table-panel temporal-table"><div className="table-summary"><strong>{meta.total} registro{meta.total === 1 ? '' : 's'} reconstruido{meta.total === 1 ? '' : 's'}</strong><span>Página {page} de {Math.max(meta.totalPages, 1)}</span></div><div className="table-scroll"><table><thead><tr><th>Registro histórico</th><th>Tipo</th><th>Estado</th><th>Versión aplicable</th><th>Vigente desde</th><th>Vigente hasta</th><th>Cambio</th><th /></tr></thead><tbody>{assets.map((asset) => <tr key={asset.assetId}><td><Link className="history-asset-link" to={`/inventarios/${asset.assetId}`}><strong>{asset.assetName}</strong><small>{asset.assetCode}</small></Link></td><td><span className="tag">{asset.typeName}</span></td><td>{assetStatusLabel(asset.informationStatus)}</td><td><span className={`version-badge ${asset.isCurrent ? 'current' : ''}`}>v{asset.versionNumber}{asset.isCurrent ? ' · actual' : ''}</span></td><td>{formatDate(asset.occurredAt)}</td><td>{asset.effectiveUntil ? formatDate(asset.effectiveUntil) : 'Continúa vigente'}</td><td>{assetVersionChangeLabel(asset.changeType)}</td><td><button type="button" className="icon-button" onClick={() => open(asset)} aria-label={`Ver versión ${asset.versionNumber}`}><Icon name="chevron" /></button></td></tr>)}</tbody></table></div><div className="pagination"><button type="button" className="button secondary" disabled={page <= 1} onClick={() => setPage(page - 1)}>Anterior</button><span>{meta.total ? `${(page - 1) * meta.pageSize + 1}–${Math.min(page * meta.pageSize, meta.total)} de ${meta.total}` : '0 resultados'}</span><button type="button" className="button secondary" disabled={page >= meta.totalPages} onClick={() => setPage(page + 1)}>Siguiente</button></div></div>}
|
||
{(detailLoading || detail) && <AssetVersionDrawer detail={detail} loading={detailLoading} onClose={() => setDetail(null)} />}
|
||
</section>;
|
||
}
|