* fix(web): simplify inventory administration menu * fix(web): remove legacy imports and function catalog routes * fix(web): remove redundant inspections lifecycle legend * feat(inventory): distinguish physical instances from structural records * fix(dashboard): align inventory and act follow-up metrics * fix(web): align dashboard summary contract * fix(web): clarify dashboard act and report concepts * feat(inventory): mark field-created records as real instances * feat(inventory): map physical instance flag on asset entity * fix(inventory): keep field yacimientos structural * feat(inventory): classify future concrete instances at database level * fix(inventory): count only installation and subinstallation instances * feat(inventory): add authoritative F5 source snapshot * feat(inventory): preload authoritative territory model * feat(inventory): preload authoritative technical catalog * fix(findings): use only authoritative F5 family catalog * fix(inventory): preserve non-hierarchical operator snapshot compatibility * feat(inventory): add inventory-only asset filter * feat(inventory): add inventory-only tree filter * feat(inventory): add inventory browser query contract * feat(inventory): add area-owned inventory browser * feat(inventory): expose area-owned inventory browser * refactor(inventory): remove function catalog and add inventory browser * fix(inventory): make operator relation temporal and non-owning * feat(web): add inventory browser API client * feat(inventory): extend inventory browser filters * feat(inventory): add real inventory list endpoint logic * feat(inventory): expose real inventory list * feat(web): add real inventory list client * refactor(web): make inventory hierarchy area-owned * fix(web): show only real inventory instances * fix(web): style act follow-up tabs and F5 inventory context * fix(web): load F5 flow styles * fix(inventory): apply area-owned operational guard on F5 up * fix(inventory): treat company on asset as non-owning creation snapshot * fix(inventory): resolve field inventory by area hierarchy, not company ownership * fix(inventory): preserve custom catalog and apply authoritative universal findings * fix(inventory): harden authoritative catalog migration checks * fix(inventory): make authoritative territory preload safely reversible * feat(inventory): allow independent company master creation * fix(inventory): make guided creation area-owned and support companies * feat(web): expose independent company master in inventory setup * feat(web): create companies independently from physical inventory hierarchy * fix(inventory): merge by physical area and preserve sealed documents * test(inventory): lock F5 authoritative model and merge invariants * feat(inventory): add family administration DTOs * feat(inventory): administer installation and subinstallation classifications * feat(inventory): expose family classification administration * feat(web): add inventory classification administration API * fix(web): configure finding applicability by inventory classification * fix(web): redefine inventory configuration around hierarchy classifications and columns * chore(release): identify F5 inventory model * chore(release): bump API for F5 inventory model * test(release): expect F5 health metadata * chore(release): align WEB package with F5 inventory cut * chore(release): expose F5 WEB phase * test(dashboard): expect inspector activity and act follow-up metrics * test(dashboard): route F5 summary query mocks explicitly * ci: rehearse all migrations on clean PostGIS before merge * ci: prove F5 migrations revert and reapply cleanly * test(f5): align operational navigation contract * test(f5): align operator lifecycle with area-owned inventory * test(f5): make merge compatibility area-based * test(f5): distinguish literal and normalized yacimiento counts * test(f5): model normalized yacimiento collision explicitly * ci(f5): bootstrap historical admin prerequisite in clean migration rehearsal * ci(f5): bypass irreversible historical reset in clean rehearsal * fix(f5): make territory SQL parameter types explicit * fix(f5): guarantee canonical inventory hierarchy before territory preload * ci(f5): include canonical hierarchy migration in rollback gate * fix(f5): type relation backup markers explicitly * fix(f5): make catalog SQL text parameter types explicit
112 lines
7.2 KiB
TypeScript
112 lines
7.2 KiB
TypeScript
import { useEffect, useState } from 'react';
|
||
import type { FormEvent } from 'react';
|
||
import { Link, useSearchParams } from 'react-router';
|
||
import { PermissionGate } from '../auth/PermissionGate';
|
||
import { Alert, EmptyState, LoadingBlock, errorMessage } from '../components/Feedback';
|
||
import { Icon } from '../components/Icon';
|
||
import { useOperationalContext } from '../context/OperationalContext';
|
||
import { OperationalFilters } from '../features/inspections/OperationalFilters';
|
||
import {
|
||
INSPECTION_VISIT_STATUSES,
|
||
inspectionStatusClass,
|
||
inspectionVisitStatusLabel,
|
||
} from '../features/inspections/inspectionPresentation';
|
||
import { listInspectionVisits } from '../lib/api';
|
||
import type {
|
||
InspectionVisitListItem,
|
||
InspectionVisitStatus,
|
||
PageMeta,
|
||
} from '../lib/api';
|
||
import { formatDate } from '../lib/format';
|
||
|
||
const statusTabs: Array<{ value: InspectionVisitStatus | ''; label: string }> = [
|
||
{ value: '', label: 'Todas' },
|
||
...INSPECTION_VISIT_STATUSES,
|
||
];
|
||
|
||
export function InspectionVisitsPage() {
|
||
const [urlParams, setUrlParams] = useSearchParams();
|
||
const context = useOperationalContext();
|
||
const [visits, setVisits] = useState<InspectionVisitListItem[]>([]);
|
||
const [meta, setMeta] = useState<PageMeta>({ page: 1, pageSize: 25, total: 0, totalPages: 0 });
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState('');
|
||
const [draftSearch, setDraftSearch] = useState(urlParams.get('search') ?? '');
|
||
const search = urlParams.get('search') ?? '';
|
||
const rawStatus = urlParams.get('status') ?? '';
|
||
const status = INSPECTION_VISIT_STATUSES.some((item) => item.value === rawStatus)
|
||
? rawStatus as InspectionVisitStatus
|
||
: '';
|
||
const companyId = context.companyId || urlParams.get('companyId') || '';
|
||
const areaId = context.areaId || urlParams.get('areaId') || '';
|
||
const inspectorId = urlParams.get('inspectorId') ?? '';
|
||
const dateFrom = urlParams.get('dateFrom') ?? '';
|
||
const dateTo = urlParams.get('dateTo') ?? '';
|
||
const page = Math.max(1, Number(urlParams.get('page') ?? 1) || 1);
|
||
|
||
useEffect(() => {
|
||
setLoading(true);
|
||
setError('');
|
||
listInspectionVisits({ page, pageSize: 25, search, status, companyId, areaId, inspectorId, dateFrom, dateTo })
|
||
.then((response) => {
|
||
setVisits(response.data);
|
||
setMeta(response.meta);
|
||
})
|
||
.catch((requestError) => setError(errorMessage(requestError)))
|
||
.finally(() => setLoading(false));
|
||
}, [page, search, status, companyId, areaId, inspectorId, dateFrom, dateTo]);
|
||
|
||
const updateFilter = (key: string, value: string) => {
|
||
const next = new URLSearchParams(urlParams);
|
||
if (key === 'areaId') {
|
||
context.setAreaId(value);
|
||
next.delete('areaId');
|
||
next.delete('companyId');
|
||
} else if (key === 'companyId') {
|
||
context.setCompanyId(value);
|
||
next.delete('companyId');
|
||
} else {
|
||
value ? next.set(key, value) : next.delete(key);
|
||
}
|
||
next.delete('page');
|
||
setUrlParams(next);
|
||
};
|
||
|
||
const applySearch = (event: FormEvent) => {
|
||
event.preventDefault();
|
||
updateFilter('search', draftSearch.trim());
|
||
};
|
||
|
||
const setPage = (value: number) => {
|
||
const next = new URLSearchParams(urlParams);
|
||
value > 1 ? next.set('page', String(value)) : next.delete('page');
|
||
setUrlParams(next);
|
||
};
|
||
|
||
const statusHref = (value: InspectionVisitStatus | '') => {
|
||
const next = new URLSearchParams(urlParams);
|
||
value ? next.set('status', value) : next.delete('status');
|
||
next.delete('page');
|
||
return `/inspecciones${next.size ? `?${next}` : ''}`;
|
||
};
|
||
|
||
return <section>
|
||
<div className="page-heading">
|
||
<div><span className="eyebrow">OPERACIÓN</span><h1>Inspecciones</h1><p>Planificá, seguí y consultá todo el ciclo de una inspección desde un único lugar.</p></div>
|
||
<PermissionGate permission="inspections.manage"><PermissionGate permission="inspections.assign"><Link className="button primary" to="/inspecciones/nueva"><Icon name="plus" />Planificar inspección</Link></PermissionGate></PermissionGate>
|
||
</div>
|
||
|
||
<nav className="inspection-status-tabs" aria-label="Estados de inspección">
|
||
{statusTabs.map((item) => <Link key={item.value || 'all'} className={status === item.value ? 'active' : ''} to={statusHref(item.value)}>{item.label}</Link>)}
|
||
</nav>
|
||
|
||
<form className="toolbar survey-toolbar" onSubmit={applySearch}>
|
||
<label className="search-field"><Icon name="search" /><input value={draftSearch} onChange={(event) => setDraftSearch(event.target.value)} placeholder="Buscar por código" /><button>Buscar</button></label>
|
||
<OperationalFilters inspectorId={inspectorId} dateFrom={dateFrom} dateTo={dateTo} onChange={updateFilter} />
|
||
</form>
|
||
|
||
{error && <Alert>{error}</Alert>}
|
||
{loading ? <LoadingBlock label="Cargando inspecciones…" /> : visits.length === 0 ? <EmptyState title="Sin inspecciones" text="No hay inspecciones que coincidan con el estado y contexto seleccionados." /> : <div className="table-panel"><div className="table-summary"><strong>{meta.total} inspección{meta.total === 1 ? '' : 'es'}</strong><span>Página {page} de {Math.max(meta.totalPages, 1)}</span></div><div className="table-scroll"><table><thead><tr><th>Inspección</th><th>Estado</th><th>Área / Operadora</th><th>Responsable</th><th>Fecha prevista</th><th>Inventario</th><th /></tr></thead><tbody>{visits.map((visit) => <tr key={visit.id}><td><div className="asset-cell"><span className="asset-symbol"><Icon name="clipboard" size={17} /></span><div><strong>{visit.code}</strong><small>{visit.operationalArea?.name ?? 'Sin Área'} · {visit.operatorCompany?.name ?? 'Sin Operadora'}</small></div></div></td><td><span className={`status-badge ${inspectionStatusClass(visit.status)}`}>{inspectionVisitStatusLabel(visit.status)}</span></td><td>{visit.operationalArea || visit.operatorCompany ? <span><strong className="table-primary">{visit.operationalArea?.name ?? 'Sin Área'}</strong><small className="cell-subtext">{visit.operatorCompany?.name ?? 'Sin Operadora'}</small></span> : <span className="muted">Sin contexto</span>}</td><td>{visit.leadInspector ? `${visit.leadInspector.firstName} ${visit.leadInspector.lastName}` : 'Sin asignar'}</td><td><span className="survey-date-range">{formatDate(visit.plannedStartAt)}<small>inicio planificado</small></span></td><td>{visit.scopeAsset ? <Link className="text-link" to={`/inventarios/${visit.scopeAsset.id}`}>{visit.scopeAsset.name}<small className="block-muted">{visit.scopeAsset.code}</small></Link> : <><strong>{visit.assetCount} registro{visit.assetCount === 1 ? '' : 's'}</strong><small className="block-muted">{visit.memberCount} integrante{visit.memberCount === 1 ? '' : 's'}</small></>}</td><td className="action-cell"><Link className="icon-button" to={`/inspecciones/${visit.id}`} aria-label={`Abrir ${visit.code}`}><Icon name="chevron" /></Link></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>}
|
||
</section>;
|
||
}
|