From da7d8a9149aa151333c8d209fe439b4f260475f9 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Sat, 5 Sep 2026 17:05:46 -0300 Subject: [PATCH] =?UTF-8?q?F1.3=20=C2=B7=20Preparaci=C3=B3n=20inteligente?= =?UTF-8?q?=20de=20campo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agrega paquete de campo por inspección con Actas anteriores del mismo contexto, hallazgos abiertos priorizados para verificación y vista web imprimible. --- api-v3/package.json | 2 +- api-v3/src/app.module.ts | 2 + .../field-briefing.controller.ts | 14 + .../field-briefing/field-briefing.module.ts | 9 + .../field-briefing/field-briefing.service.ts | 310 ++++++++++++++++++ api-v3/src/version.ts | 4 +- .../unit/phase-f1-3-field-briefing.test.ts | 37 +++ web-v2/package.json | 2 +- web-v2/src/app/App.tsx | 4 +- web-v2/src/config/version.ts | 4 +- web-v2/src/layout/AppLayout.tsx | 1 + web-v2/src/pages/FieldBriefingsPage.tsx | 191 +++++++++++ 12 files changed, 573 insertions(+), 7 deletions(-) create mode 100644 api-v3/src/field-briefing/field-briefing.controller.ts create mode 100644 api-v3/src/field-briefing/field-briefing.module.ts create mode 100644 api-v3/src/field-briefing/field-briefing.service.ts create mode 100644 api-v3/test/unit/phase-f1-3-field-briefing.test.ts create mode 100644 web-v2/src/pages/FieldBriefingsPage.tsx diff --git a/api-v3/package.json b/api-v3/package.json index a7408b5..0ff719f 100644 --- a/api-v3/package.json +++ b/api-v3/package.json @@ -1,6 +1,6 @@ { "name": "dhv2-api", - "version": "0.20.0-2", + "version": "0.20.0-3", "private": true, "license": "UNLICENSED", "scripts": { diff --git a/api-v3/src/app.module.ts b/api-v3/src/app.module.ts index ed63e7f..5185669 100644 --- a/api-v3/src/app.module.ts +++ b/api-v3/src/app.module.ts @@ -25,6 +25,7 @@ import { AssetImportsModule } from './asset-imports/asset-imports.module'; import { InspectionReportsModule } from './inspection-reports/inspection-reports.module'; import { InspectionVerificationsModule } from './inspection-verifications/inspection-verifications.module'; import { ActAdministrationModule } from './act-administration/act-administration.module'; +import { FieldBriefingModule } from './field-briefing/field-briefing.module'; function required(config: ConfigService, key: string): string { const value = config.get(key); @@ -78,6 +79,7 @@ function required(config: ConfigService, key: string): string { InspectionReportsModule, InspectionVerificationsModule, ActAdministrationModule, + FieldBriefingModule, AssetImportsModule, ], controllers: [HealthController], diff --git a/api-v3/src/field-briefing/field-briefing.controller.ts b/api-v3/src/field-briefing/field-briefing.controller.ts new file mode 100644 index 0000000..9dee466 --- /dev/null +++ b/api-v3/src/field-briefing/field-briefing.controller.ts @@ -0,0 +1,14 @@ +import { Controller, Get, Param, ParseUUIDPipe } from '@nestjs/common'; +import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator'; +import { FieldBriefingService } from './field-briefing.service'; + +@Controller('inspection-visits/:visitId/field-briefing') +export class FieldBriefingController { + constructor(private readonly briefing: FieldBriefingService) {} + + @Get() + @RequirePermissions('inspections.read') + get(@Param('visitId', new ParseUUIDPipe({ version: '4' })) visitId: string) { + return this.briefing.getForVisit(visitId); + } +} diff --git a/api-v3/src/field-briefing/field-briefing.module.ts b/api-v3/src/field-briefing/field-briefing.module.ts new file mode 100644 index 0000000..096623d --- /dev/null +++ b/api-v3/src/field-briefing/field-briefing.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { FieldBriefingController } from './field-briefing.controller'; +import { FieldBriefingService } from './field-briefing.service'; + +@Module({ + controllers: [FieldBriefingController], + providers: [FieldBriefingService], +}) +export class FieldBriefingModule {} diff --git a/api-v3/src/field-briefing/field-briefing.service.ts b/api-v3/src/field-briefing/field-briefing.service.ts new file mode 100644 index 0000000..434a31b --- /dev/null +++ b/api-v3/src/field-briefing/field-briefing.service.ts @@ -0,0 +1,310 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { DataSource } from 'typeorm'; + +export type FieldBriefingAdminState = + | 'NEW' + | 'WAITING_RESPONSE' + | 'DUE_SOON' + | 'OVERDUE' + | 'RESPONSE_RECEIVED' + | 'VERIFICATION_PENDING' + | 'COMMITMENT_OVERDUE'; + +export type FieldBriefingReviewState = 'REQUIRED' | 'UPCOMING' | 'CONTEXT'; + +interface BriefingVisitRow { + id: string; + code: string; + status: string; + plannedStartAt: Date | null; + areaId: string | null; + areaCode: string | null; + areaName: string | null; + companyId: string | null; + companyCode: string | null; + companyName: string | null; +} + +interface BriefingFindingRow { + sourceActId: string; + sourceActCode: string; + sourceActOccurredAt: Date; + sourceVisitId: string; + sourceVisitCode: string; + responseDueOn: string | null; + responseReceivedOn: string | null; + committedCorrectionOn: string | null; + latestResponseId: string | null; + responseHasPdf: boolean; + adminState: FieldBriefingAdminState; + reviewState: FieldBriefingReviewState; + reviewReason: string; + priority: number; + findingId: string; + findingCode: string; + findingTitle: string; + severity: number | null; + nextControlOn: string | null; + latestVerificationOutcome: string | null; + assetId: string; + assetCode: string; + assetName: string; + assetTypeName: string; +} + +@Injectable() +export class FieldBriefingService { + constructor(private readonly dataSource: DataSource) {} + + async getForVisit(visitId: string) { + const [visit] = (await this.dataSource.query(` + SELECT + visit.id, + visit.code, + visit.status, + visit.planned_start_at AS "plannedStartAt", + visit.operational_area_id AS "areaId", + area.code AS "areaCode", + area.name AS "areaName", + visit.operator_company_id AS "companyId", + company.code AS "companyCode", + COALESCE(profile.legal_name, company.name) AS "companyName" + FROM inspection_visits visit + LEFT JOIN assets area ON area.id = visit.operational_area_id + LEFT JOIN assets company ON company.id = visit.operator_company_id + LEFT JOIN organization_profiles profile ON profile.asset_id = company.id + WHERE visit.id = $1 + `, [visitId])) as BriefingVisitRow[]; + + if (!visit) { + throw new NotFoundException({ + code: 'INSPECTION_VISIT_NOT_FOUND', + message: 'Inspección no encontrada', + }); + } + if (!visit.areaId || !visit.companyId) { + throw new BadRequestException({ + code: 'FIELD_BRIEFING_CONTEXT_REQUIRED', + message: 'La inspección debe tener Área y Operadora antes de preparar el paquete de campo', + }); + } + + const referenceAt = visit.plannedStartAt ?? new Date(); + const referenceDate = referenceAt.toISOString().slice(0, 10); + + const rows = (await this.dataSource.query(` + SELECT + act.id AS "sourceActId", + act.code AS "sourceActCode", + act.occurred_at AS "sourceActOccurredAt", + source_visit.id AS "sourceVisitId", + source_visit.code AS "sourceVisitCode", + deadline.response_due_on AS "responseDueOn", + response.received_on AS "responseReceivedOn", + response.committed_correction_on AS "committedCorrectionOn", + response.id AS "latestResponseId", + (response.stored_name IS NOT NULL) AS "responseHasPdf", + CASE + WHEN response.id IS NOT NULL + AND response.committed_correction_on IS NOT NULL + AND response.committed_correction_on < $5::date + THEN 'COMMITMENT_OVERDUE' + WHEN response.id IS NOT NULL AND finding.next_control_on IS NULL + THEN 'VERIFICATION_PENDING' + WHEN response.id IS NOT NULL THEN 'RESPONSE_RECEIVED' + WHEN deadline.id IS NULL THEN 'NEW' + WHEN deadline.response_due_on < $5::date THEN 'OVERDUE' + WHEN deadline.response_due_on <= ($5::date + 3) THEN 'DUE_SOON' + ELSE 'WAITING_RESPONSE' + END AS "adminState", + CASE + WHEN COALESCE(verification.outcome, '') <> 'RESOLVED' + AND finding.next_control_on IS NOT NULL + AND finding.next_control_on <= $5::date + THEN 'REQUIRED' + WHEN response.id IS NOT NULL + AND response.committed_correction_on IS NOT NULL + AND response.committed_correction_on <= $5::date + THEN 'REQUIRED' + WHEN response.id IS NOT NULL AND finding.next_control_on IS NULL + THEN 'REQUIRED' + WHEN COALESCE(verification.outcome, '') <> 'RESOLVED' + AND finding.next_control_on IS NOT NULL + AND finding.next_control_on <= ($5::date + 30) + THEN 'UPCOMING' + ELSE 'CONTEXT' + END AS "reviewState", + CASE + WHEN COALESCE(verification.outcome, '') <> 'RESOLVED' + AND finding.next_control_on IS NOT NULL + AND finding.next_control_on <= $5::date + THEN 'CONTROL_OVERDUE' + WHEN response.id IS NOT NULL + AND response.committed_correction_on IS NOT NULL + AND response.committed_correction_on <= $5::date + THEN 'COMMITMENT_REACHED' + WHEN response.id IS NOT NULL AND finding.next_control_on IS NULL + THEN 'RESPONSE_WITHOUT_CONTROL' + WHEN COALESCE(verification.outcome, '') <> 'RESOLVED' + AND finding.next_control_on IS NOT NULL + AND finding.next_control_on <= ($5::date + 30) + THEN 'CONTROL_UPCOMING' + WHEN response.id IS NULL + AND deadline.response_due_on IS NOT NULL + AND deadline.response_due_on < $5::date + THEN 'ADMIN_RESPONSE_OVERDUE' + ELSE 'CONTEXT_ONLY' + END AS "reviewReason", + CASE + WHEN COALESCE(verification.outcome, '') <> 'RESOLVED' + AND finding.next_control_on IS NOT NULL + AND finding.next_control_on <= $5::date THEN 1 + WHEN response.id IS NOT NULL + AND response.committed_correction_on IS NOT NULL + AND response.committed_correction_on <= $5::date THEN 2 + WHEN response.id IS NOT NULL AND finding.next_control_on IS NULL THEN 3 + WHEN COALESCE(verification.outcome, '') <> 'RESOLVED' + AND finding.next_control_on IS NOT NULL + AND finding.next_control_on <= ($5::date + 30) THEN 4 + WHEN response.id IS NULL + AND deadline.response_due_on IS NOT NULL + AND deadline.response_due_on < $5::date THEN 5 + ELSE 6 + END AS priority, + finding.id AS "findingId", + finding.code AS "findingCode", + finding.title AS "findingTitle", + finding.severity, + finding.next_control_on AS "nextControlOn", + verification.outcome AS "latestVerificationOutcome", + asset.id AS "assetId", + asset.code AS "assetCode", + asset.name AS "assetName", + asset_type.name AS "assetTypeName" + FROM inspection_acts act + INNER JOIN inspection_visits source_visit ON source_visit.id = act.visit_id + INNER JOIN inspection_findings finding ON finding.act_id = act.id + INNER JOIN assets asset ON asset.id = finding.asset_id + INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id + LEFT JOIN LATERAL ( + SELECT event.id, event.response_due_on + FROM inspection_act_deadline_events event + WHERE event.act_id = act.id + ORDER BY event.created_at DESC, event.id DESC + LIMIT 1 + ) deadline ON true + LEFT JOIN LATERAL ( + SELECT company_response.id, company_response.received_on, + company_response.committed_correction_on, company_response.stored_name + FROM inspection_act_company_responses company_response + WHERE company_response.act_id = act.id + ORDER BY company_response.received_on DESC, + company_response.created_at DESC, + company_response.id DESC + LIMIT 1 + ) response ON true + LEFT JOIN LATERAL ( + SELECT verification_visit.outcome + FROM inspection_finding_verification_visits verification_visit + WHERE verification_visit.finding_id = finding.id + AND verification_visit.outcome IS NOT NULL + ORDER BY verification_visit.result_recorded_at DESC NULLS LAST, + verification_visit.created_at DESC, + verification_visit.id DESC + LIMIT 1 + ) verification ON true + WHERE source_visit.operational_area_id = $2 + AND source_visit.operator_company_id = $3 + AND source_visit.id <> $1 + AND act.status IN ('CLOSED', 'RECTIFIED') + AND finding.status = 'OPEN' + AND COALESCE(act.closed_at, act.occurred_at) <= $4::timestamptz + ORDER BY priority, act.occurred_at, act.act_number, finding.finding_number + `, [visit.id, visit.areaId, visit.companyId, referenceAt.toISOString(), referenceDate])) as BriefingFindingRow[]; + + const actMap = new Map>; + }>(); + + for (const row of rows) { + let act = actMap.get(row.sourceActId); + if (!act) { + act = { + actId: row.sourceActId, + actCode: row.sourceActCode, + occurredAt: row.sourceActOccurredAt, + sourceVisit: { id: row.sourceVisitId, code: row.sourceVisitCode }, + adminState: row.adminState, + responseDueOn: row.responseDueOn, + latestResponse: row.latestResponseId && row.responseReceivedOn ? { + id: row.latestResponseId, + receivedOn: row.responseReceivedOn, + committedCorrectionOn: row.committedCorrectionOn, + hasPdf: Boolean(row.responseHasPdf), + } : null, + findings: [], + }; + actMap.set(row.sourceActId, act); + } + act.findings.push({ + id: row.findingId, + code: row.findingCode, + title: row.findingTitle, + severity: row.severity, + nextControlOn: row.nextControlOn, + latestVerificationOutcome: row.latestVerificationOutcome, + reviewState: row.reviewState, + reviewReason: row.reviewReason, + priority: Number(row.priority), + asset: { + id: row.assetId, + code: row.assetCode, + name: row.assetName, + typeName: row.assetTypeName, + }, + }); + } + + const acts = Array.from(actMap.values()); + const uniqueAssets = new Set(rows.map((row) => row.assetId)); + const required = rows.filter((row) => row.reviewState === 'REQUIRED').length; + const upcoming = rows.filter((row) => row.reviewState === 'UPCOMING').length; + const adminAttention = rows.filter((row) => + ['OVERDUE', 'DUE_SOON', 'COMMITMENT_OVERDUE'].includes(row.adminState), + ).length; + + return { + visit: { + id: visit.id, + code: visit.code, + status: visit.status, + plannedStartAt: visit.plannedStartAt, + operationalArea: { id: visit.areaId, code: visit.areaCode, name: visit.areaName }, + operatorCompany: { id: visit.companyId, code: visit.companyCode, name: visit.companyName }, + }, + referenceDate, + generatedAt: new Date().toISOString(), + summary: { + actCount: acts.length, + openFindingCount: rows.length, + fieldReviewRequired: required, + fieldReviewUpcoming: upcoming, + administrativeAttention: adminAttention, + assetCount: uniqueAssets.size, + }, + acts, + }; + } +} diff --git a/api-v3/src/version.ts b/api-v3/src/version.ts index 9dcbfe6..f0686ca 100644 --- a/api-v3/src/version.ts +++ b/api-v3/src/version.ts @@ -1,2 +1,2 @@ -export const API_VERSION = '0.20.0-2'; -export const API_PHASE = 'F1.2'; +export const API_VERSION = '0.20.0-3'; +export const API_PHASE = 'F1.3'; diff --git a/api-v3/test/unit/phase-f1-3-field-briefing.test.ts b/api-v3/test/unit/phase-f1-3-field-briefing.test.ts new file mode 100644 index 0000000..b0ea47d --- /dev/null +++ b/api-v3/test/unit/phase-f1-3-field-briefing.test.ts @@ -0,0 +1,37 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import test from 'node:test'; + +const source = (path: string) => readFileSync(resolve(process.cwd(), path), 'utf8'); +const service = source('src/field-briefing/field-briefing.service.ts'); +const controller = source('src/field-briefing/field-briefing.controller.ts'); + +test('F1.3 prepares field briefing from prior Acts in the same operational context', () => { + assert.match(service, /source_visit\.operational_area_id = \$2/); + assert.match(service, /source_visit\.operator_company_id = \$3/); + assert.match(service, /source_visit\.id <> \$1/); +}); + +test('F1.3 uses Act-level deadline and company response ledgers', () => { + assert.match(service, /inspection_act_deadline_events/); + assert.match(service, /inspection_act_company_responses/); + assert.doesNotMatch(service, /company_response_received_on/); +}); + +test('F1.3 only turns open findings from closed Acts into pending field context', () => { + assert.match(service, /act\.status IN \('CLOSED', 'RECTIFIED'\)/); + assert.match(service, /finding\.status = 'OPEN'/); +}); + +test('F1.3 separates required field review, upcoming review and administrative context', () => { + assert.match(service, /'REQUIRED'/); + assert.match(service, /'UPCOMING'/); + assert.match(service, /'CONTEXT'/); + assert.match(service, /ADMIN_RESPONSE_OVERDUE/); +}); + +test('F1.3 exposes the briefing through the inspection read contract', () => { + assert.match(controller, /field-briefing/); + assert.match(controller, /inspections\.read/); +}); diff --git a/web-v2/package.json b/web-v2/package.json index d5e2351..431bb57 100644 --- a/web-v2/package.json +++ b/web-v2/package.json @@ -1,6 +1,6 @@ { "name": "dhv2-web", - "version": "0.20.0-2", + "version": "0.20.0-3", "private": true, "type": "module", "engines": { diff --git a/web-v2/src/app/App.tsx b/web-v2/src/app/App.tsx index 546bef8..101aaea 100644 --- a/web-v2/src/app/App.tsx +++ b/web-v2/src/app/App.tsx @@ -34,10 +34,11 @@ import { ReportDetailPage } from '../pages/ReportDetailPage'; import { VerificationPlanningPage } from '../pages/VerificationPlanningPage'; import { ActAdministrationPage } from '../pages/ActAdministrationPage'; import { ActAdministrationDetailPage } from '../pages/ActAdministrationDetailPage'; +import { FieldBriefingsPage } from '../pages/FieldBriefingsPage'; const MapPage = lazy(() => import('../pages/MapPage').then((module) => ({ default: module.MapPage }))); - const DocumentDeliveryPage = lazy(() => import('../pages/DocumentDeliveryPage').then((module) => ({ default: module.DocumentDeliveryPage }))); + export function App() { return Cargando módulo…}> } /> @@ -65,6 +66,7 @@ export function App() { }> } /> } /> + } /> }>} /> }> diff --git a/web-v2/src/config/version.ts b/web-v2/src/config/version.ts index 0c59f2d..1fce13f 100644 --- a/web-v2/src/config/version.ts +++ b/web-v2/src/config/version.ts @@ -1,2 +1,2 @@ -export const APP_VERSION = '0.20.0-2'; -export const APP_PHASE = 'Fase F1.2 · Seguimiento administrativo por Acta'; +export const APP_VERSION = '0.20.0-3'; +export const APP_PHASE = 'Fase F1.3 · Preparación inteligente de campo'; diff --git a/web-v2/src/layout/AppLayout.tsx b/web-v2/src/layout/AppLayout.tsx index 5e804f5..72bdae1 100644 --- a/web-v2/src/layout/AppLayout.tsx +++ b/web-v2/src/layout/AppLayout.tsx @@ -16,6 +16,7 @@ interface NavItem { const operational: NavItem[] = [ { to: '/', label: 'Inicio', icon: 'home', permission: 'dashboard.read' }, { to: '/inspecciones', label: 'Inspecciones', icon: 'clipboard', permission: 'inspections.read' }, + { to: '/preparacion-campo', label: 'Preparación de campo', icon: 'clipboard', permission: 'inspections.read' }, ]; const followUp: NavItem[] = [ diff --git a/web-v2/src/pages/FieldBriefingsPage.tsx b/web-v2/src/pages/FieldBriefingsPage.tsx new file mode 100644 index 0000000..c409320 --- /dev/null +++ b/web-v2/src/pages/FieldBriefingsPage.tsx @@ -0,0 +1,191 @@ +import { useEffect, useMemo, useState } from 'react'; +import { Link } from 'react-router'; +import { SearchableSelect } from '../components/SearchableSelect'; +import { Alert, EmptyState, LoadingBlock } from '../components/Feedback'; +import { formatDate, formatDateOnly } from '../lib/format'; + +type ReviewState = 'REQUIRED' | 'UPCOMING' | 'CONTEXT'; + +interface VisitOption { + id: string; + code: string; + status: string; + plannedStartAt: string | null; + operationalArea: { name: string } | null; + operatorCompany: { name: string } | null; +} + +interface BriefingFinding { + id: string; + code: string; + title: string; + severity: number | null; + nextControlOn: string | null; + reviewState: ReviewState; + reviewReason: string; + asset: { id: string; code: string; name: string; typeName: string }; +} + +interface BriefingAct { + actId: string; + actCode: string; + occurredAt: string; + adminState: string; + responseDueOn: string | null; + latestResponse: null | { id: string; receivedOn: string; committedCorrectionOn: string | null; hasPdf: boolean }; + findings: BriefingFinding[]; +} + +interface Briefing { + visit: { + id: string; + code: string; + status: string; + plannedStartAt: string | null; + operationalArea: { id: string; code: string | null; name: string | null }; + operatorCompany: { id: string; code: string | null; name: string | null }; + }; + referenceDate: string; + generatedAt: string; + summary: { + actCount: number; + openFindingCount: number; + fieldReviewRequired: number; + fieldReviewUpcoming: number; + administrativeAttention: number; + assetCount: number; + }; + acts: BriefingAct[]; +} + +async function json(url: string): Promise { + 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 cargar la preparación de campo.'; + throw new Error(message); + } + return payload as T; +} + +function reviewLabel(state: ReviewState) { + if (state === 'REQUIRED') return 'Revisar en campo'; + if (state === 'UPCOMING') return 'Próximo control'; + return 'Antecedente'; +} + +function reasonLabel(reason: string) { + const labels: Record = { + CONTROL_OVERDUE: 'Control vencido', + COMMITMENT_REACHED: 'Compromiso de empresa alcanzado', + RESPONSE_WITHOUT_CONTROL: 'Respuesta recibida sin control programado', + CONTROL_UPCOMING: 'Control próximo', + ADMIN_RESPONSE_OVERDUE: 'Respuesta administrativa vencida', + CONTEXT_ONLY: 'Antecedente abierto', + }; + return labels[reason] ?? reason; +} + +export function FieldBriefingsPage() { + const [visits, setVisits] = useState([]); + const [visitId, setVisitId] = useState(''); + const [briefing, setBriefing] = useState(null); + const [loadingVisits, setLoadingVisits] = useState(true); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + useEffect(() => { + setLoadingVisits(true); + Promise.all([ + json<{ data: VisitOption[] }>('/api/v3/inspection-visits?status=PLANNED&page=1&pageSize=100'), + json<{ 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 (all.length === 1) setVisitId(all[0].id); + }).catch((cause) => setError(cause instanceof Error ? cause.message : String(cause))) + .finally(() => setLoadingVisits(false)); + }, []); + + useEffect(() => { + if (!visitId) { + setBriefing(null); + return; + } + setLoading(true); + setError(''); + json(`/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
+
+
+ ANTES DE SALIR A CAMPO +

Preparación de campo

+

Actas anteriores y hallazgos abiertos del mismo Área/Yacimiento y Operadora, ordenados por lo que el inspector debe revisar.

+
+ {briefing && } +
+ +
+ + {loadingVisits ? : setVisitId(event.target.value)} + searchPlaceholder="Buscar por código, Área u Operadora…" + > + + {visits.map((visit) => )} + } + {selected?.plannedStartAt && Salida prevista: {formatDate(selected.plannedStartAt)}} +
+ + {error && {error}} + {loading && } + {!loading && !briefing && !error && } + {!loading && briefing && <> +
+
+ {briefing.visit.code} +

{briefing.visit.operationalArea.name ?? 'Área'} · {briefing.visit.operatorCompany.name ?? 'Operadora'}

+

Referencia: {formatDateOnly(briefing.referenceDate)} · generado {formatDate(briefing.generatedAt)}

+
+ Abrir planificación +
+ +
+ Actas {briefing.summary.actCount} + Hallazgos abiertos {briefing.summary.openFindingCount} + Revisar en campo {briefing.summary.fieldReviewRequired} + Próximos {briefing.summary.fieldReviewUpcoming} + Inventario involucrado {briefing.summary.assetCount} +
+ + {briefing.acts.length === 0 ? : briefing.acts.map((act) =>
+
+
{act.actCode}{formatDate(act.occurredAt)} · {act.adminState}
+
Plazo empresa{act.responseDueOn ? formatDateOnly(act.responseDueOn) : 'Sin definir'}
+
Respuesta{act.latestResponse ? formatDateOnly(act.latestResponse.receivedOn) : 'Pendiente'}
+
+
+ {act.findings.map((finding) => + + + + + )} +
PrioridadHallazgoInventarioPróximo control
{reviewLabel(finding.reviewState)}{reasonLabel(finding.reviewReason)}{finding.code}{finding.title}{finding.asset.code}{finding.asset.name} · {finding.asset.typeName}{finding.nextControlOn ? formatDateOnly(finding.nextControlOn) : 'Sin fecha'}
+
)} + } +
; +}