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

180 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 {
listFindingWorklistF4,
} from '../lib/findingWorklistF4Api';
import type {
FindingWorklistCountersF4,
FindingWorklistItemF4,
FindingWorklistWorkflowF4,
} from '../lib/findingWorklistF4Api';
import { formatDateOnly } from '../lib/format';
const workflows: Array<{ value: FindingWorklistWorkflowF4; label: string }> = [
{ value: 'OPEN', label: 'Abiertos' },
{ value: 'TO_SCHEDULE_VERIFICATION', label: 'Sin fecha de control' },
{ value: 'TO_VERIFY', label: 'Con control programado' },
{ value: 'VERIFICATION_OVERDUE', label: 'Verificación vencida' },
{ value: 'READY_TO_CLOSE', label: 'Listos para cerrar' },
{ value: 'CLOSED', label: 'Cerrados' },
{ value: 'ALL', label: 'Todos' },
];
const emptyCounters: FindingWorklistCountersF4 = {
open: 0,
withoutControlDate: 0,
toVerify: 0,
verificationOverdue: 0,
verificationNext30Days: 0,
readyToClose: 0,
closed: 0,
};
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: FindingWorklistItemF4): { label: string; className: string } {
const today = todayInput();
if (finding.status === 'CLOSED') return { label: 'Cerrado', className: 'active' };
if (finding.latestVerification?.outcome === 'RESOLVED' && finding.latestVerification.visitStatus === 'CLOSED') {
return { label: 'Listo para cerrar', className: 'active' };
}
if (!finding.nextControlOn) return { label: 'Sin fecha de control', className: 'observed' };
if (finding.verificationVisit) return { label: 'Verificación planificada', className: 'blue' };
if (finding.nextControlOn < today) return { label: 'Verificación vencida', className: 'danger' };
return { label: 'Control programado', className: 'pending' };
}
function deadlineText(finding: FindingWorklistItemF4): { 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 };
}
return {
title: finding.nextControlOn ? 'Verificación DH' : 'Sin fecha definida',
value: finding.nextControlOn,
overdue: Boolean(finding.nextControlOn && finding.nextControlOn < today),
};
}
export function FindingsPage() {
const [params, setParams] = useSearchParams();
const operationalContext = useOperationalContext();
const [items, setItems] = useState<FindingWorklistItemF4[]>([]);
const [meta, setMeta] = useState({ page: 1, pageSize: 25, total: 0, totalPages: 0 });
const [counters, setCounters] = useState<FindingWorklistCountersF4>(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 FindingWorklistWorkflowF4
: '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('');
listFindingWorklistF4({ 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 TÉCNICO</span><h1>Hallazgos</h1><p>Observaciones originales, evidencia, fechas de control, verificaciones y cierre técnico. Las presentaciones administrativas de empresa se siguen desde el INF.</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>Hallazgo</strong><small>Observación y Descripción registradas en el Acta.</small></div>
<i></i>
<div><span>2</span><strong>Fecha de control</strong><small>Se define cuándo corresponde volver a verificar.</small></div>
<i></i>
<div><span>3</span><strong>Verificación</strong><small>El inspector registra el resultado y la evidencia en campo.</small></div>
<i></i>
<div><span>4</span><strong>Cierre</strong><small>Se cierra cuando la solución queda técnicamente verificada.</small></div>
</div>
<div className="finding-attention-grid">
<button type="button" className={workflow === 'TO_SCHEDULE_VERIFICATION' ? 'active' : ''} onClick={() => setFilter('workflow', 'TO_SCHEDULE_VERIFICATION')}><small>SIN FECHA DE CONTROL</small><strong>{counters.withoutControlDate}</strong><span>Requieren definición operativa</span></button>
<button type="button" className={workflow === 'TO_VERIFY' ? 'active' : ''} onClick={() => setFilter('workflow', 'TO_VERIFY')}><small>CON CONTROL</small><strong>{counters.toVerify}</strong><span>{counters.verificationNext30Days} en próximos 30 días</span></button>
<button type="button" className={workflow === 'VERIFICATION_OVERDUE' ? 'active danger' : 'danger'} onClick={() => setFilter('workflow', 'VERIFICATION_OVERDUE')}><small>VERIFICACIÓN VENCIDA</small><strong>{counters.verificationOverdue}</strong><span>Requieren prioridad</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, Descripción, 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 técnicas 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>Fecha de control</th><th>Descripció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.description}</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>;
}