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

191 lines
11 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 { SearchableSelect } from '../components/SearchableSelect';
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 { listInspectionFindingsGlobal } from '../lib/api';
import type {
InspectionFinding,
InspectionFindingWorkflow,
InspectionFindingWorkflowCounters,
PageMeta,
} from '../lib/api';
import { formatDateOnly } from '../lib/format';
const workflows: Array<{ value: InspectionFindingWorkflow; label: string }> = [
{ value: 'OPEN', label: 'Abiertos' },
{ value: 'WAITING_COMPANY', label: 'Esperando empresa' },
{ value: 'COMPANY_OVERDUE', label: 'Respuesta vencida' },
{ value: 'TO_SCHEDULE_VERIFICATION', label: 'Programar verificación' },
{ value: 'TO_VERIFY', label: 'Para verificar' },
{ value: 'VERIFICATION_OVERDUE', label: 'Verificación vencida' },
{ value: 'READY_TO_CLOSE', label: 'Listos para cerrar' },
{ value: 'CLOSED', label: 'Cerrados' },
{ value: 'ALL', label: 'Todos' },
];
function todayInput(): string {
const now = new Date();
const local = new Date(now.getTime() - now.getTimezoneOffset() * 60_000);
return local.toISOString().slice(0, 10);
}
function attentionLabel(finding: InspectionFinding): { label: string; className: string } {
const today = todayInput();
if (finding.latestVerification?.outcome === 'RESOLVED' && finding.latestVerification.visitStatus === 'CLOSED') return { label: 'Listo para cerrar', className: 'active' };
if (finding.status === 'CLOSED') return { label: 'Cerrado', className: 'active' };
if (!finding.companyResponseReceivedOn) {
if (finding.correctionDueOn && finding.correctionDueOn < today) return { label: 'Respuesta vencida', className: 'danger' };
return { label: 'Esperando empresa', className: 'pending' };
}
if (!finding.nextControlOn) return { label: 'Programar verificación', className: 'observed' };
if (finding.nextControlOn < today) return { label: 'Verificación vencida', className: 'danger' };
return { label: 'Para verificar', className: 'blue' };
}
function deadlineText(finding: InspectionFinding): { title: string; value: string | null; overdue: boolean } {
const today = todayInput();
if (finding.latestVerification?.outcome === 'RESOLVED' && finding.latestVerification.visitStatus === 'CLOSED') return { title: 'Verificado conforme', value: finding.latestVerification.verifiedAt, overdue: false };
if (!finding.companyResponseReceivedOn) {
return {
title: 'Respuesta de empresa',
value: finding.correctionDueOn,
overdue: Boolean(finding.correctionDueOn && finding.correctionDueOn < today),
};
}
return {
title: 'Verificación DH',
value: finding.nextControlOn,
overdue: Boolean(finding.nextControlOn && finding.nextControlOn < today),
};
}
const emptyCounters: InspectionFindingWorkflowCounters = {
open: 0,
waitingCompany: 0,
companyOverdue: 0,
companyDueNext7Days: 0,
awaitingVerificationSchedule: 0,
toVerify: 0,
verificationOverdue: 0,
verificationNext30Days: 0,
readyToClose: 0,
closed: 0,
};
export function FindingsPage() {
const [params, setParams] = useSearchParams();
const operationalContext = useOperationalContext();
const [items, setItems] = useState<InspectionFinding[]>([]);
const [meta, setMeta] = useState<PageMeta>({ page: 1, pageSize: 25, total: 0, totalPages: 0 });
const [counters, setCounters] = useState(emptyCounters);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const workflowValue = params.get('workflow') ?? 'OPEN';
const workflow = workflows.some((item) => item.value === workflowValue)
? workflowValue as InspectionFindingWorkflow
: 'OPEN';
const search = params.get('search') ?? '';
const [draftSearch, setDraftSearch] = useState(search);
const companyId = operationalContext.companyId || params.get('companyId') || '';
const areaId = operationalContext.areaId || params.get('areaId') || '';
const inspectorId = params.get('inspectorId') ?? '';
const dateFrom = params.get('dateFrom') ?? '';
const dateTo = params.get('dateTo') ?? '';
const page = Math.max(1, Number(params.get('page') ?? 1) || 1);
useEffect(() => {
setLoading(true);
setError('');
listInspectionFindingsGlobal({ page, pageSize: 25, search, workflow, companyId, areaId, inspectorId, dateFrom, dateTo })
.then((response) => {
setItems(response.data);
setMeta(response.meta);
setCounters(response.counters);
})
.catch((requestError) => setError(errorMessage(requestError)))
.finally(() => setLoading(false));
}, [page, search, workflow, companyId, areaId, inspectorId, dateFrom, dateTo]);
const setFilter = (key: string, value: string) => {
const next = new URLSearchParams(params);
if (key === 'areaId') {
operationalContext.setAreaId(value);
next.delete('areaId');
next.delete('companyId');
} else if (key === 'companyId') {
operationalContext.setCompanyId(value);
next.delete('companyId');
} else {
value ? next.set(key, value) : next.delete(key);
}
next.delete('page');
setParams(next);
};
const applySearch = (event: FormEvent) => {
event.preventDefault();
setFilter('search', draftSearch.trim());
};
const setPage = (value: number) => {
const next = new URLSearchParams(params);
value > 1 ? next.set('page', String(value)) : next.delete('page');
setParams(next);
};
return <section>
<div className="page-heading">
<div><span className="eyebrow">SEGUIMIENTO</span><h1>Hallazgos</h1><p>Bandeja de trabajo para respuestas de empresas, vencimientos, verificaciones y cierres.</p></div>
<PermissionGate permission="inspection_verifications.plan"><Link className="button primary" to="/hallazgos/planificacion"><Icon name="calendar" />Planificar verificaciones</Link></PermissionGate>
</div>
<div className="finding-workflow-explainer">
<div><span>1</span><strong>Vencimiento administrativo</strong><small>Fecha límite para recibir la respuesta de la empresa.</small></div>
<i></i>
<div><span>2</span><strong>Respuesta recibida</strong><small>Se carga la presentación y su documentación.</small></div>
<i></i>
<div><span>3</span><strong>Vencimiento de verificación</strong><small>Fecha para volver a inspeccionar o verificar la solución.</small></div>
<i></i>
<div><span>4</span><strong>Cierre</strong><small>El hallazgo se cierra cuando la solución queda verificada.</small></div>
</div>
<div className="finding-attention-grid">
<button type="button" className={workflow === 'WAITING_COMPANY' ? 'active' : ''} onClick={() => setFilter('workflow', 'WAITING_COMPANY')}><small>ESPERANDO EMPRESA</small><strong>{counters.waitingCompany}</strong><span>{counters.companyOverdue} vencidos</span></button>
<button type="button" className={workflow === 'COMPANY_OVERDUE' ? 'active danger' : 'danger'} onClick={() => setFilter('workflow', 'COMPANY_OVERDUE')}><small>RESPUESTA VENCIDA</small><strong>{counters.companyOverdue}</strong><span>{counters.companyDueNext7Days} vencen en 7 días</span></button>
<button type="button" className={workflow === 'TO_SCHEDULE_VERIFICATION' ? 'active' : ''} onClick={() => setFilter('workflow', 'TO_SCHEDULE_VERIFICATION')}><small>PROGRAMAR VERIFICACIÓN</small><strong>{counters.awaitingVerificationSchedule}</strong><span>Respuesta ya recibida</span></button>
<button type="button" className={workflow === 'TO_VERIFY' || workflow === 'VERIFICATION_OVERDUE' ? 'active' : ''} onClick={() => setFilter('workflow', 'TO_VERIFY')}><small>PARA VERIFICAR</small><strong>{counters.toVerify}</strong><span>{counters.verificationOverdue} vencidos</span></button>
<button type="button" className={workflow === 'READY_TO_CLOSE' ? 'active' : ''} onClick={() => setFilter('workflow', 'READY_TO_CLOSE')}><small>LISTOS PARA CERRAR</small><strong>{counters.readyToClose}</strong><span>Verificados conformes</span></button>
</div>
<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 hallazgo, empresa, área, acta o elemento" /><button>Buscar</button></label>
<label className="select-field"><span>Vista</span><SearchableSelect value={workflow} onChange={(event) => setFilter('workflow', event.target.value)}>{workflows.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</SearchableSelect></label>
<OperationalFilters inspectorId={inspectorId} dateFrom={dateFrom} dateTo={dateTo} onChange={setFilter} />
</form>
{error && <Alert>{error}</Alert>}
{loading ? <LoadingBlock label="Cargando hallazgos…" /> : items.length === 0 ? <EmptyState title="Sin hallazgos en esta vista" text="No hay tareas pendientes para el filtro seleccionado." /> : <div className="table-panel findings-worklist">
<div className="table-summary"><strong>{meta.total} hallazgo{meta.total === 1 ? '' : 's'}</strong><span>Página {page} de {Math.max(meta.totalPages, 1)}</span></div>
<div className="table-scroll"><table><thead><tr><th>Hallazgo</th><th>Empresa / área</th><th>Elemento</th><th>Situación</th><th>Próximo vencimiento</th><th>Documentación</th><th /></tr></thead><tbody>{items.map((finding) => {
const attention = attentionLabel(finding);
const deadline = deadlineText(finding);
return <tr key={finding.id}>
<td><div className="finding-table-primary"><strong>{finding.title}</strong><small>{finding.code} · {finding.document.actCode}</small></div></td>
<td><div className="finding-table-primary"><strong>{finding.asset.operatorCompany?.name ?? 'Empresa sin asignar'}</strong><small>{finding.asset.operationalArea?.name ?? 'Área sin asignar'}</small></div></td>
<td><Link className="text-link" to={`/inventarios/${finding.assetId}`}>{finding.asset.name}<small className="block-muted">{finding.asset.code}</small></Link></td>
<td><span className={`status-badge ${attention.className}`}>{attention.label}</span></td>
<td><div className={`finding-deadline ${deadline.overdue ? 'overdue' : ''}`}><small>{deadline.title}</small><strong>{formatDateOnly(deadline.value)}</strong></div></td>
<td><small>{finding.companyResponseReceivedOn ? `Respuesta ${formatDateOnly(finding.companyResponseReceivedOn)}` : 'Sin respuesta'}</small></td>
<td className="action-cell"><Link className="icon-button" to={`/hallazgos/${finding.id}`} aria-label={`Abrir ${finding.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>;
}