chore: import DH V2 D5.6.4 production baseline

This commit is contained in:
DH V2
2026-09-05 10:12:35 -03:00
commit 82213e72f5
757 changed files with 84218 additions and 0 deletions
+155
View File
@@ -0,0 +1,155 @@
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,
operatorCompanyId: matchFor.asset.operatorCompanyId ?? 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 Empresa y Área. 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>;
}