Files
dh-inspeccion-v2/web-v2/src/pages/FieldDiscoveriesPage.tsx
T
ChatGPT DH 1beba14c3b
DH V2 CI / API · typecheck, tests, build (push) Successful in 53s
Production dependency audit / API · production dependencies (push) Successful in 19s
Production dependency audit / WEB · production dependencies (push) Successful in 16s
DH V2 CI / WEB · typecheck, build (push) Successful in 1m34s
DH V2 CI / Docker / migrations / production images (push) Successful in 2m49s
DH V2 CI / Promote verified main to deploy (push) Successful in 3s
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 7m22s
feat(web): make active tables sortable
2026-09-16 19:20:41 -03:00

156 lines
9.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { SortableTable } from '../components/SortableTable';
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 {
approveFieldDiscovery,
listAssets,
listFieldDiscoveries,
matchFieldDiscovery,
rejectFieldDiscovery,
} from '../lib/api';
import type { AssetListItem, FieldDiscovery, FieldDiscoveryStatus, PageMeta } from '../lib/api';
import { formatDate } from '../lib/format';
const statuses: Array<{ value: FieldDiscoveryStatus | ''; label: string }> = [
{ value: 'PENDING', label: 'Pendientes' },
{ value: 'APPROVED', label: 'Aprobadas' },
{ value: 'MATCHED', label: 'Conciliadas' },
{ value: 'REJECTED', label: 'Rechazadas' },
{ value: '', label: 'Todas' },
];
function statusLabel(status: FieldDiscoveryStatus) {
if (status === 'APPROVED') return 'Aprobada';
if (status === 'MATCHED') return 'Conciliada';
if (status === 'REJECTED') return 'Rechazada';
return 'Pendiente';
}
export function FieldDiscoveriesPage() {
const [items, setItems] = useState<FieldDiscovery[]>([]);
const [meta, setMeta] = useState<PageMeta>({ page: 1, pageSize: 25, total: 0, totalPages: 0 });
const [status, setStatus] = useState<FieldDiscoveryStatus | ''>('PENDING');
const [search, setSearch] = useState('');
const [draftSearch, setDraftSearch] = useState('');
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
const [busyId, setBusyId] = useState('');
const [error, setError] = useState('');
const [matchFor, setMatchFor] = useState<FieldDiscovery | null>(null);
const [matchSearch, setMatchSearch] = useState('');
const [candidates, setCandidates] = useState<AssetListItem[]>([]);
const [matchLoading, setMatchLoading] = useState(false);
const load = () => {
setLoading(true); setError('');
listFieldDiscoveries({ page, pageSize: 25, status: status || undefined, search })
.then((response) => { setItems(response.data); setMeta(response.meta); })
.catch((requestError) => setError(errorMessage(requestError)))
.finally(() => setLoading(false));
};
useEffect(load, [page, status, search]);
const applySearch = (event: FormEvent) => {
event.preventDefault();
setPage(1); setSearch(draftSearch.trim());
};
const approve = async (item: FieldDiscovery) => {
if (!window.confirm(`¿Validar ${item.asset.name} e incorporarlo al Inventario?`)) return;
setBusyId(item.id); setError('');
try { await approveFieldDiscovery(item.id); load(); }
catch (requestError) { setError(errorMessage(requestError)); }
finally { setBusyId(''); }
};
const reject = async (item: FieldDiscovery) => {
const reason = window.prompt('Motivo del rechazo (queda registrado en el historial):', '');
if (!reason || reason.trim().length < 5) return;
setBusyId(item.id); setError('');
try { await rejectFieldDiscovery(item.id, reason.trim()); load(); }
catch (requestError) { setError(errorMessage(requestError)); }
finally { setBusyId(''); }
};
const searchMatches = async (event: FormEvent) => {
event.preventDefault();
if (!matchFor || matchSearch.trim().length < 2) return;
setMatchLoading(true); setError('');
try {
const response = await listAssets({
page: 1,
pageSize: 12,
search: matchSearch.trim(),
operationalAreaId: matchFor.asset.operationalAreaId ?? undefined,
});
setCandidates(response.data.filter((candidate) => candidate.id !== matchFor.asset.id && candidate.informationStatus !== 'INACTIVE'));
} catch (requestError) { setError(errorMessage(requestError)); }
finally { setMatchLoading(false); }
};
const chooseMatch = async (candidate: AssetListItem) => {
if (!matchFor) return;
const reason = window.prompt(`Confirmá por qué ${matchFor.asset.name} corresponde a ${candidate.name}:`, 'Coincidencia verificada en oficina');
if (!reason || reason.trim().length < 5) return;
setBusyId(matchFor.id); setError('');
try {
await matchFieldDiscovery(matchFor.id, candidate.id, reason.trim());
setMatchFor(null); setMatchSearch(''); setCandidates([]); load();
} catch (requestError) { setError(errorMessage(requestError)); }
finally { setBusyId(''); }
};
return <section>
<div className="page-heading asset-center-heading">
<div><span className="eyebrow">INVENTARIOS</span><h1>Altas encontradas en campo</h1><p>Revisá elementos creados por inspectores durante una visita antes de incorporarlos definitivamente al Inventario.</p></div>
</div>
<AssetCenterTabs active="field" />
<div className="asset-center-controls">
<form className="asset-center-search" onSubmit={applySearch}>
<Icon name="search" />
<input value={draftSearch} onChange={(event) => setDraftSearch(event.target.value)} placeholder="Buscar nombre, código, inspección o inspector…" />
<button className="button primary" type="submit">Buscar</button>
</form>
<div className="quick-view-row">
{statuses.map((option) => <button key={option.label} type="button" className={status === option.value ? 'active' : ''} onClick={() => { setPage(1); setStatus(option.value); }}>{option.label}</button>)}
</div>
</div>
{error && <Alert>{error}</Alert>}
<Alert>Una alta de campo queda en borrador. Podés corregir su ficha, aprobarla, conciliarla con un registro existente o rechazarla. Las Actas y Hallazgos que ya la referencien no se reescriben.</Alert>
{loading ? <LoadingBlock label="Cargando altas de campo…" /> : items.length === 0 ? <EmptyState title="No hay altas en esta bandeja" text="Cuando un inspector registre un elemento nuevo durante una visita aparecerá aquí." /> : <div className="table-panel">
<div className="table-summary"><strong>{meta.total} alta{meta.total === 1 ? '' : 's'}</strong><span>Página {meta.page} de {Math.max(meta.totalPages, 1)}</span></div>
<div className="table-scroll"><SortableTable><thead><tr><th>Elemento</th><th>Inspección</th><th>Inspector</th><th>Estado</th><th>Acciones</th></tr></thead><tbody>
{items.map((item) => <tr key={item.id}>
<td><Link className="table-primary" to={`/inventarios/${item.asset.id}`}>{item.asset.name}</Link><small className="cell-subtext">{item.asset.code} · {item.asset.typeName}{item.asset.commonName ? ` · ${item.asset.commonName}` : ''}</small></td>
<td><Link to={`/inspecciones/${item.visit.id}`}>{item.visit.code}</Link><small className="cell-subtext">{formatDate(item.observedAt)}</small></td>
<td><strong className="table-primary">{item.creator.firstName} {item.creator.lastName}</strong><small className="cell-subtext">{item.creator.username}</small></td>
<td><span className={`status-badge ${item.status === 'PENDING' ? 'warning' : item.status === 'APPROVED' ? 'success' : item.status === 'MATCHED' ? 'info' : 'muted'}`}>{statusLabel(item.status)}</span>{item.matchedAsset && <small className="cell-subtext"> {item.matchedAsset.name}</small>}</td>
<td className="action-cell">
<Link className="button secondary compact" to={`/inventarios/${item.asset.id}`}>Corregir ficha</Link>
{item.status === 'PENDING' && <>
<button className="button primary compact" disabled={busyId === item.id} onClick={() => approve(item)}>Aprobar</button>
<button className="button secondary compact" disabled={busyId === item.id} onClick={() => { setMatchFor(item); setMatchSearch(''); setCandidates([]); }}>Conciliar</button>
<button className="button danger-outline compact" disabled={busyId === item.id} onClick={() => reject(item)}>Rechazar</button>
</>}
</td>
</tr>)}
</tbody></SortableTable></div>
<div className="pagination"><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 className="button secondary" disabled={page >= meta.totalPages} onClick={() => setPage(page + 1)}>Siguiente</button></div>
</div>}
{matchFor && <div className="panel field-discovery-match-panel">
<div className="panel-heading"><div><span className="eyebrow">CONCILIAR</span><h2>Buscar registro existente</h2><p className="section-copy">Buscá dentro de la misma Área física. El alta de campo se archivará, pero su historial no se elimina.</p></div><button className="button text" onClick={() => setMatchFor(null)}>Cerrar</button></div>
<form className="asset-center-search" onSubmit={searchMatches}><Icon name="search" /><input value={matchSearch} onChange={(event) => setMatchSearch(event.target.value)} placeholder="Nombre, sobrenombre o código existente…" /><button className="button primary" disabled={matchLoading}>{matchLoading ? 'Buscando…' : 'Buscar'}</button></form>
{candidates.length > 0 && <div className="table-scroll"><SortableTable><thead><tr><th>Registro existente</th><th>Tipo</th><th /></tr></thead><tbody>{candidates.map((candidate) => <tr key={candidate.id}><td><strong>{candidate.name}</strong><small className="cell-subtext">{candidate.code}{candidate.commonName ? ` · ${candidate.commonName}` : ''}</small></td><td>{candidate.type.name}</td><td className="action-cell"><button className="button primary compact" onClick={() => chooseMatch(candidate)}>Usar esta coincidencia</button></td></tr>)}</tbody></SortableTable></div>}
</div>}
</section>;
}