155 lines
9.5 KiB
TypeScript
155 lines
9.5 KiB
TypeScript
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>;
|
||
}
|