Files
dh-inspeccion-v2/web-v2/src/pages/FieldBriefingsPage.tsx
T
admin 7be5d89204 F6.1 hotfix · jerarquía de Inspecciones y scope Yacimiento (#37)
Corrige creación de Inspección, separa Departamento→Área→Yacimiento, elimina el UPDATE oculto de checklist en lectura, integra Preparación para campo como etapa de Inspección, limita operaciones Android al Yacimiento, preserva scope en edición, auto-incluye Inventarios accionables, valida Operadora a plannedStartAt y recompila Android 0.15.2/vc24 con el ícono exacto de presentación.
2026-09-10 16:06:48 -03:00

273 lines
10 KiB
TypeScript

import { useEffect, useMemo, useState } from 'react';
import { Link, useSearchParams } from 'react-router';
import { SearchableSelect } from '../components/SearchableSelect';
import { Alert, EmptyState, LoadingBlock } from '../components/Feedback';
import { formatDate, formatDateOnly } from '../lib/format';
type AdminState =
| 'ACT_RESPONSE_OVERDUE'
| 'ACT_RESPONSE_DUE_SOON'
| 'WAITING_RESPONSE'
| 'COMPANY_COMMITMENT_OVERDUE'
| 'VERIFICATION_PENDING'
| 'RESPONSE_RECEIVED';
interface VisitOption {
id: string;
code: string;
status: string;
plannedStartAt: string | null;
scopeAsset?: { name: string; typeName?: string } | null;
operationalArea: { name: string } | null;
operatorCompany: { name: string } | null;
}
interface BriefingFinding {
id: string;
code: string;
title: string;
status: string;
severity: number | null;
nextControlOn: string | null;
asset: {
id: string;
code: string;
name: string;
typeName: string;
};
}
interface BriefingAct {
actId: string;
actCode: string;
occurredAt: string;
adminState: AdminState;
responseDueOn: string | null;
responseReceivedOn: string | null;
committedCorrectionOn: string | null;
latestResponseId: string | null;
findings: BriefingFinding[];
}
interface FieldBriefing {
inspection: {
id: string;
code: string;
status: string;
plannedStartAt: string | null;
area: { id: string; code: string | null; name: string | null } | null;
operatorCompany: { id: string; code: string | null; name: string | null } | null;
};
plannedOn: string;
summary: {
acts: number;
findings: number;
inventoryItems: number;
responseOverdue: number;
commitmentOverdue: number;
verificationPending: number;
};
acts: BriefingAct[];
}
async function getJson<T>(url: string): Promise<T> {
const response = await fetch(url, {
credentials: 'same-origin',
headers: { Accept: 'application/json' },
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
const message = typeof payload?.message === 'string'
? payload.message
: 'No se pudo preparar el paquete de campo.';
throw new Error(message);
}
return payload as T;
}
function adminStateLabel(state: AdminState): string {
const labels: Record<AdminState, string> = {
ACT_RESPONSE_OVERDUE: 'Respuesta del Acta vencida',
ACT_RESPONSE_DUE_SOON: 'Respuesta próxima a vencer',
WAITING_RESPONSE: 'Esperando respuesta',
COMPANY_COMMITMENT_OVERDUE: 'Compromiso de empresa vencido',
VERIFICATION_PENDING: 'Verificación pendiente',
RESPONSE_RECEIVED: 'Respuesta recibida',
};
return labels[state];
}
function findingAction(finding: BriefingFinding, act: BriefingAct, plannedOn: string) {
if (finding.nextControlOn) {
if (finding.nextControlOn <= plannedOn) {
return { label: 'Revisar en campo', detail: 'Control programado o vencido', tone: 'danger' };
}
return { label: 'Próximo control', detail: `Previsto ${formatDateOnly(finding.nextControlOn)}`, tone: 'pending' };
}
if (act.adminState === 'VERIFICATION_PENDING') {
return { label: 'Revisar en campo', detail: 'Respuesta recibida sin control programado', tone: 'danger' };
}
if (act.adminState === 'COMPANY_COMMITMENT_OVERDUE') {
return { label: 'Revisar en campo', detail: 'Regularización comprometida vencida', tone: 'danger' };
}
if (act.adminState === 'RESPONSE_RECEIVED') {
return { label: 'Antecedente', detail: 'Respuesta de empresa recibida', tone: 'pending' };
}
return { label: 'Antecedente', detail: adminStateLabel(act.adminState), tone: 'pending' };
}
export function FieldBriefingsPage() {
const [searchParams] = useSearchParams();
const requestedInspectionId = searchParams.get('inspectionId') ?? '';
const [visits, setVisits] = useState<VisitOption[]>([]);
const [visitId, setVisitId] = useState(requestedInspectionId);
const [briefing, setBriefing] = useState<FieldBriefing | null>(null);
const [loadingVisits, setLoadingVisits] = useState(true);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
setLoadingVisits(true);
Promise.all([
getJson<{ data: VisitOption[] }>('/api/v3/inspection-visits?status=PLANNED&page=1&pageSize=100'),
getJson<{ data: VisitOption[] }>('/api/v3/inspection-visits?status=DRAFT&page=1&pageSize=100'),
]).then(([planned, drafts]) => {
const all = [...planned.data, ...drafts.data]
.sort((a, b) => String(a.plannedStartAt ?? '').localeCompare(String(b.plannedStartAt ?? '')));
setVisits(all);
if (requestedInspectionId && all.some((visit) => visit.id === requestedInspectionId)) {
setVisitId(requestedInspectionId);
} else if (all.length === 1 && all[0]) {
setVisitId(all[0].id);
}
}).catch((cause) => {
setError(cause instanceof Error ? cause.message : String(cause));
}).finally(() => setLoadingVisits(false));
}, [requestedInspectionId]);
useEffect(() => {
if (!visitId) {
setBriefing(null);
return;
}
setLoading(true);
setError('');
getJson<FieldBriefing>(`/api/v3/inspection-visits/${visitId}/field-briefing`)
.then(setBriefing)
.catch((cause) => setError(cause instanceof Error ? cause.message : String(cause)))
.finally(() => setLoading(false));
}, [visitId]);
const selected = useMemo(
() => visits.find((visit) => visit.id === visitId) ?? null,
[visits, visitId],
);
return <section className="field-briefing-page">
<div className="breadcrumb"><Link to="/inspecciones">Inspecciones</Link><span>/</span><span>Preparación para campo</span></div>
<div className="page-heading">
<div>
<span className="eyebrow">ETAPA DE LA INSPECCIÓN</span>
<h1>Preparación para campo</h1>
<p>Revisá antecedentes y pendientes antes de iniciar la Inspección desde la APK.</p>
</div>
{briefing && <button className="button secondary no-print" type="button" onClick={() => window.print()}>
Imprimir preparación
</button>}
</div>
<div className="form-card no-print">
<label>Inspección</label>
{loadingVisits ? <LoadingBlock label="Cargando inspecciones…" /> : <SearchableSelect
value={visitId}
onChange={(event) => setVisitId(event.target.value)}
searchPlaceholder="Buscar por código, Yacimiento, Área u Operadora…"
>
<option value="">Seleccionar inspección</option>
{visits.map((visit) => <option key={visit.id} value={visit.id}>
{visit.code} · {visit.scopeAsset?.name ?? 'Sin Yacimiento'} · {visit.operationalArea?.name ?? 'Sin Área'} · {visit.operatorCompany?.name ?? 'Sin Operadora'}
</option>)}
</SearchableSelect>}
{selected?.plannedStartAt && <small className="block-muted">Salida prevista: {formatDate(selected.plannedStartAt)}</small>}
</div>
{error && <Alert>{error}</Alert>}
{loading && <LoadingBlock label="Armando paquete de campo…" />}
{!loading && !briefing && !error && <EmptyState
title="Seleccioná una inspección"
text="El sistema reunirá automáticamente sus antecedentes y controles pendientes."
/>}
{!loading && briefing && <>
<div className="page-heading compact">
<div>
<span className="eyebrow">{briefing.inspection.code}</span>
<h2>{selected?.scopeAsset?.name ?? 'Yacimiento sin definir'} · {briefing.inspection.area?.name ?? 'Área sin definir'}</h2>
<p>{briefing.inspection.operatorCompany?.name ?? 'Operadora sin definir'} · Fecha de referencia: {formatDateOnly(briefing.plannedOn)}</p>
</div>
<Link className="button secondary no-print" to={`/inspecciones/${briefing.inspection.id}`}>
Volver a la inspección
</Link>
</div>
<div className="status-tabs briefing-summary">
<span>Actas <strong>{briefing.summary.acts}</strong></span>
<span>Hallazgos <strong>{briefing.summary.findings}</strong></span>
<span>Inventario <strong>{briefing.summary.inventoryItems}</strong></span>
<span>Respuestas vencidas <strong>{briefing.summary.responseOverdue}</strong></span>
<span>Compromisos vencidos <strong>{briefing.summary.commitmentOverdue}</strong></span>
<span>Verificaciones pendientes <strong>{briefing.summary.verificationPending}</strong></span>
</div>
{briefing.acts.length === 0 ? <EmptyState
title="Sin pendientes anteriores"
text="No hay Actas anteriores con hallazgos abiertos que requieran atención para esta inspección."
/> : briefing.acts.map((act) => <article className="table-panel" key={act.actId}>
<div className="table-summary">
<div>
<strong>{act.actCode}</strong>
<small className="block-muted">{formatDate(act.occurredAt)} · {adminStateLabel(act.adminState)}</small>
</div>
<div>
<small>Plazo del Acta</small>
<strong>{act.responseDueOn ? formatDateOnly(act.responseDueOn) : 'Sin definir'}</strong>
</div>
<div>
<small>Respuesta empresa</small>
<strong>{act.responseReceivedOn ? formatDateOnly(act.responseReceivedOn) : 'Pendiente'}</strong>
</div>
<div>
<small>Compromiso empresa</small>
<strong>{act.committedCorrectionOn ? formatDateOnly(act.committedCorrectionOn) : 'Sin fecha'}</strong>
</div>
</div>
<div className="table-scroll">
<table>
<thead><tr><th>Qué hacer</th><th>Hallazgo</th><th>Inventario</th><th>Control</th></tr></thead>
<tbody>{act.findings.map((finding) => {
const action = findingAction(finding, act, briefing.plannedOn);
return <tr key={finding.id}>
<td>
<span className={`status-badge ${action.tone}`}>{action.label}</span>
<small className="block-muted">{action.detail}</small>
</td>
<td>
<strong>{finding.code}</strong>
<small className="block-muted">{finding.title}</small>
</td>
<td>
<strong>{finding.asset.code}</strong>
<small className="block-muted">{finding.asset.name} · {finding.asset.typeName}</small>
</td>
<td>{finding.nextControlOn ? formatDateOnly(finding.nextControlOn) : 'Sin fecha'}</td>
</tr>;
})}</tbody>
</table>
</div>
</article>)}
</>}
</section>;
}