Files
dh-inspeccion-v2/web-v2/src/pages/FieldDiscoveriesPage.tsx
T

155 lines
9.5 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 { 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"><table><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></table></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"><table><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></table></div>}
</div>}
</section>;
}