F4: make findings worklist technical-only
This commit is contained in:
@@ -7,69 +7,29 @@ import { Alert, EmptyState, LoadingBlock, errorMessage } from '../components/Fee
|
|||||||
import { Icon } from '../components/Icon';
|
import { Icon } from '../components/Icon';
|
||||||
import { useOperationalContext } from '../context/OperationalContext';
|
import { useOperationalContext } from '../context/OperationalContext';
|
||||||
import { OperationalFilters } from '../features/inspections/OperationalFilters';
|
import { OperationalFilters } from '../features/inspections/OperationalFilters';
|
||||||
import { listInspectionFindingsGlobal } from '../lib/api';
|
import {
|
||||||
|
listFindingWorklistF4,
|
||||||
|
} from '../lib/findingWorklistF4Api';
|
||||||
import type {
|
import type {
|
||||||
InspectionFinding,
|
FindingWorklistCountersF4,
|
||||||
InspectionFindingWorkflow,
|
FindingWorklistItemF4,
|
||||||
InspectionFindingWorkflowCounters,
|
FindingWorklistWorkflowF4,
|
||||||
PageMeta,
|
} from '../lib/findingWorklistF4Api';
|
||||||
} from '../lib/api';
|
|
||||||
import { formatDateOnly } from '../lib/format';
|
import { formatDateOnly } from '../lib/format';
|
||||||
|
|
||||||
const workflows: Array<{ value: InspectionFindingWorkflow; label: string }> = [
|
const workflows: Array<{ value: FindingWorklistWorkflowF4; label: string }> = [
|
||||||
{ value: 'OPEN', label: 'Abiertos' },
|
{ value: 'OPEN', label: 'Abiertos' },
|
||||||
{ value: 'WAITING_COMPANY', label: 'Esperando empresa' },
|
{ value: 'TO_SCHEDULE_VERIFICATION', label: 'Sin fecha de control' },
|
||||||
{ value: 'COMPANY_OVERDUE', label: 'Respuesta vencida' },
|
{ value: 'TO_VERIFY', label: 'Con control programado' },
|
||||||
{ value: 'TO_SCHEDULE_VERIFICATION', label: 'Programar verificación' },
|
|
||||||
{ value: 'TO_VERIFY', label: 'Para verificar' },
|
|
||||||
{ value: 'VERIFICATION_OVERDUE', label: 'Verificación vencida' },
|
{ value: 'VERIFICATION_OVERDUE', label: 'Verificación vencida' },
|
||||||
{ value: 'READY_TO_CLOSE', label: 'Listos para cerrar' },
|
{ value: 'READY_TO_CLOSE', label: 'Listos para cerrar' },
|
||||||
{ value: 'CLOSED', label: 'Cerrados' },
|
{ value: 'CLOSED', label: 'Cerrados' },
|
||||||
{ value: 'ALL', label: 'Todos' },
|
{ value: 'ALL', label: 'Todos' },
|
||||||
];
|
];
|
||||||
|
|
||||||
function todayInput(): string {
|
const emptyCounters: FindingWorklistCountersF4 = {
|
||||||
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,
|
open: 0,
|
||||||
waitingCompany: 0,
|
withoutControlDate: 0,
|
||||||
companyOverdue: 0,
|
|
||||||
companyDueNext7Days: 0,
|
|
||||||
awaitingVerificationSchedule: 0,
|
|
||||||
toVerify: 0,
|
toVerify: 0,
|
||||||
verificationOverdue: 0,
|
verificationOverdue: 0,
|
||||||
verificationNext30Days: 0,
|
verificationNext30Days: 0,
|
||||||
@@ -77,17 +37,47 @@ const emptyCounters: InspectionFindingWorkflowCounters = {
|
|||||||
closed: 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() {
|
export function FindingsPage() {
|
||||||
const [params, setParams] = useSearchParams();
|
const [params, setParams] = useSearchParams();
|
||||||
const operationalContext = useOperationalContext();
|
const operationalContext = useOperationalContext();
|
||||||
const [items, setItems] = useState<InspectionFinding[]>([]);
|
const [items, setItems] = useState<FindingWorklistItemF4[]>([]);
|
||||||
const [meta, setMeta] = useState<PageMeta>({ page: 1, pageSize: 25, total: 0, totalPages: 0 });
|
const [meta, setMeta] = useState({ page: 1, pageSize: 25, total: 0, totalPages: 0 });
|
||||||
const [counters, setCounters] = useState(emptyCounters);
|
const [counters, setCounters] = useState<FindingWorklistCountersF4>(emptyCounters);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const workflowValue = params.get('workflow') ?? 'OPEN';
|
const workflowValue = params.get('workflow') ?? 'OPEN';
|
||||||
const workflow = workflows.some((item) => item.value === workflowValue)
|
const workflow = workflows.some((item) => item.value === workflowValue)
|
||||||
? workflowValue as InspectionFindingWorkflow
|
? workflowValue as FindingWorklistWorkflowF4
|
||||||
: 'OPEN';
|
: 'OPEN';
|
||||||
const search = params.get('search') ?? '';
|
const search = params.get('search') ?? '';
|
||||||
const [draftSearch, setDraftSearch] = useState(search);
|
const [draftSearch, setDraftSearch] = useState(search);
|
||||||
@@ -101,7 +91,7 @@ export function FindingsPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
listInspectionFindingsGlobal({ page, pageSize: 25, search, workflow, companyId, areaId, inspectorId, dateFrom, dateTo })
|
listFindingWorklistF4({ page, pageSize: 25, search, workflow, companyId, areaId, inspectorId, dateFrom, dateTo })
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
setItems(response.data);
|
setItems(response.data);
|
||||||
setMeta(response.meta);
|
setMeta(response.meta);
|
||||||
@@ -140,38 +130,37 @@ export function FindingsPage() {
|
|||||||
|
|
||||||
return <section>
|
return <section>
|
||||||
<div className="page-heading">
|
<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>
|
<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>
|
<PermissionGate permission="inspection_verifications.plan"><Link className="button primary" to="/hallazgos/planificacion"><Icon name="calendar" />Planificar verificaciones</Link></PermissionGate>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="finding-workflow-explainer">
|
<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>
|
<div><span>1</span><strong>Hallazgo</strong><small>Observación y Descripción registradas en el Acta.</small></div>
|
||||||
<i>→</i>
|
<i>→</i>
|
||||||
<div><span>2</span><strong>Respuesta recibida</strong><small>Se carga la presentación y su documentación.</small></div>
|
<div><span>2</span><strong>Fecha de control</strong><small>Se define cuándo corresponde volver a verificar.</small></div>
|
||||||
<i>→</i>
|
<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>
|
<div><span>3</span><strong>Verificación</strong><small>El inspector registra el resultado y la evidencia en campo.</small></div>
|
||||||
<i>→</i>
|
<i>→</i>
|
||||||
<div><span>4</span><strong>Cierre</strong><small>El hallazgo se cierra cuando la solución queda verificada.</small></div>
|
<div><span>4</span><strong>Cierre</strong><small>Se cierra cuando la solución queda técnicamente verificada.</small></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="finding-attention-grid">
|
<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 === '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 === '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_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 === '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 === '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 === '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>
|
<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>
|
</div>
|
||||||
|
|
||||||
<form className="toolbar survey-toolbar" onSubmit={applySearch}>
|
<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="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>
|
<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} />
|
<OperationalFilters inspectorId={inspectorId} dateFrom={dateFrom} dateTo={dateTo} onChange={setFilter} />
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{error && <Alert>{error}</Alert>}
|
{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">
|
{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-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) => {
|
<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 attention = attentionLabel(finding);
|
||||||
const deadline = deadlineText(finding);
|
const deadline = deadlineText(finding);
|
||||||
return <tr key={finding.id}>
|
return <tr key={finding.id}>
|
||||||
@@ -180,7 +169,7 @@ export function FindingsPage() {
|
|||||||
<td><Link className="text-link" to={`/inventarios/${finding.assetId}`}>{finding.asset.name}<small className="block-muted">{finding.asset.code}</small></Link></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><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><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><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>
|
<td className="action-cell"><Link className="icon-button" to={`/hallazgos/${finding.id}`} aria-label={`Abrir ${finding.code}`}><Icon name="chevron" /></Link></td>
|
||||||
</tr>;
|
</tr>;
|
||||||
})}</tbody></table></div>
|
})}</tbody></table></div>
|
||||||
|
|||||||
Reference in New Issue
Block a user