From a414d0ed36db385e8a38d93929316beae881f9be Mon Sep 17 00:00:00 2001 From: KoreX Labs Date: Wed, 16 Sep 2026 08:29:19 -0300 Subject: [PATCH] fix(web): clarify act context and operational map --- api-v3/package-lock.json | 4 +- api-v3/package.json | 2 +- .../asset-geometries.controller.ts | 22 ++ .../asset-master/asset-geometries.service.ts | 220 ++++++++++++++++++ .../src/asset-master/asset-master.module.ts | 4 + .../inspection-acts.service.ts | 82 ++++++- .../inspection-findings.service.ts | 50 +++- api-v3/src/version.ts | 4 +- api-v3/test/unit/f4-health-metadata.test.ts | 6 +- .../f6-1-presentation-ready-contract.test.ts | 2 +- .../test/unit/f6-15-act-map-context.test.ts | 60 +++++ docs/PHASE_F6_15_WEB_ACT_MAP.md | 23 ++ web-v2/package-lock.json | 4 +- web-v2/package.json | 2 +- web-v2/src/config/version.ts | 4 +- .../inspections/InspectionActMediaPanel.tsx | 13 +- web-v2/src/features/map/DhMap.tsx | 29 ++- web-v2/src/lib/api.ts | 47 +++- web-v2/src/lib/inspectionActF4Api.ts | 12 + web-v2/src/pages/ActsPage.tsx | 6 +- web-v2/src/pages/InspectionActEditorPage.tsx | 15 +- web-v2/src/pages/MapPage.tsx | 102 ++++---- web-v2/src/styles.css | 6 + 23 files changed, 643 insertions(+), 76 deletions(-) create mode 100644 api-v3/test/unit/f6-15-act-map-context.test.ts create mode 100644 docs/PHASE_F6_15_WEB_ACT_MAP.md diff --git a/api-v3/package-lock.json b/api-v3/package-lock.json index 4bca88a..6a64119 100644 --- a/api-v3/package-lock.json +++ b/api-v3/package-lock.json @@ -1,12 +1,12 @@ { "name": "dhv2-api", - "version": "0.29.0-14", + "version": "0.29.0-15", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dhv2-api", - "version": "0.29.0-14", + "version": "0.29.0-15", "license": "UNLICENSED", "dependencies": { "@nestjs/common": "^11.0.0", diff --git a/api-v3/package.json b/api-v3/package.json index 6118e33..45e901e 100644 --- a/api-v3/package.json +++ b/api-v3/package.json @@ -1,6 +1,6 @@ { "name": "dhv2-api", - "version": "0.29.0-14", + "version": "0.29.0-15", "private": true, "license": "UNLICENSED", "scripts": { diff --git a/api-v3/src/asset-master/asset-geometries.controller.ts b/api-v3/src/asset-master/asset-geometries.controller.ts index 2534a8a..22b517b 100644 --- a/api-v3/src/asset-master/asset-geometries.controller.ts +++ b/api-v3/src/asset-master/asset-geometries.controller.ts @@ -63,3 +63,25 @@ export class MapAssetsController { return this.geometries.map(query); } } + +@Controller('map/context') +export class MapOperationalContextController { + constructor(private readonly geometries: AssetGeometriesService) {} + + @Get() + @RequirePermissions('assets.read') + map() { + return this.geometries.mapOperationalContext(); + } +} + +@Controller('map/documents') +export class MapDocumentsController { + constructor(private readonly geometries: AssetGeometriesService) {} + + @Get() + @RequirePermissions('assets.read', 'inspection_acts.read', 'inspection_findings.read') + map() { + return this.geometries.mapDocuments(); + } +} diff --git a/api-v3/src/asset-master/asset-geometries.service.ts b/api-v3/src/asset-master/asset-geometries.service.ts index fceef25..1846439 100644 --- a/api-v3/src/asset-master/asset-geometries.service.ts +++ b/api-v3/src/asset-master/asset-geometries.service.ts @@ -230,6 +230,8 @@ export class AssetGeometriesService { geometry: row.geometry, properties: { id: row.id, + entityId: row.id, + entityKind: 'ASSET', code: row.code, name: row.name, typeId: row.typeId, @@ -242,12 +244,230 @@ export class AssetGeometriesService { accuracyM: row.accuracyM == null ? null : Number(row.accuracyM), capturedAt: row.capturedAt, updatedAt: row.updatedAt, + href: `/inventarios/${row.id}`, + contextLine: row.parentName ? `Depende de ${row.parentName}` : null, }, })), meta: { count: visible.length, truncated }, }; } + async mapOperationalContext() { + const rows = (await this.dataSource.query(` + WITH RECURSIVE y_tree AS ( + SELECT y.id AS yacimiento_id, y.id AS asset_id + FROM assets y + INNER JOIN asset_types y_type ON y_type.id=y.asset_type_id + WHERE lower(y_type.code)='yacimiento' + AND y_type.is_active=true + AND y.information_status<>'INACTIVE' + UNION ALL + SELECT tree.yacimiento_id, child.id + FROM y_tree tree + INNER JOIN assets child ON child.parent_id=tree.asset_id + WHERE child.information_status<>'INACTIVE' + ), y_loc AS ( + SELECT tree.yacimiento_id, + ST_Centroid(ST_Collect(geometry.geometry)) AS geometry, + MAX(geometry.updated_at) AS updated_at, + COUNT(*)::integer AS source_geometries + FROM y_tree tree + INNER JOIN asset_geometries geometry ON geometry.asset_id=tree.asset_id + GROUP BY tree.yacimiento_id + ), base AS ( + SELECT + y.id AS yacimiento_id,y.code AS yacimiento_code,y.name AS yacimiento_name, + y.information_status AS yacimiento_status, + area.id AS area_id,area.code AS area_code,area.name AS area_name, + department.id AS department_id,department.code AS department_code,department.name AS department_name, + company.id AS company_id,company.code AS company_code,COALESCE(profile.legal_name,company.name) AS company_name, + loc.geometry,loc.updated_at,loc.source_geometries + FROM y_loc loc + INNER JOIN assets y ON y.id=loc.yacimiento_id + LEFT JOIN assets area ON area.id=y.parent_id + LEFT JOIN assets department ON department.id=area.parent_id + LEFT JOIN assets company ON company.id=y.operator_company_id + LEFT JOIN organization_profiles profile ON profile.asset_id=company.id + ) + SELECT + 'YACIMIENTO:'||base.yacimiento_id::text AS "featureId", + base.yacimiento_id AS "entityId",'YACIMIENTO'::text AS "entityKind", + ST_AsGeoJSON(base.geometry)::jsonb AS geometry, + base.yacimiento_code AS code,base.yacimiento_name AS name, + 'Yacimiento'::text AS "typeName",base.yacimiento_status::text AS "informationStatus", + 'POINT'::text AS "geometryType",base.updated_at AS "updatedAt", + '/inventarios/'||base.yacimiento_id::text AS href, + concat_ws(' · ',base.department_name,base.area_name,base.company_name) AS "contextLine", + base.department_name AS "departmentName",base.area_name AS "areaName", + base.yacimiento_name AS "yacimientoName",base.company_name AS "companyName", + base.source_geometries AS "sourceGeometries" + FROM base + UNION ALL + SELECT + 'COMPANY:'||base.company_id::text||':'||base.yacimiento_id::text AS "featureId", + base.company_id AS "entityId",'COMPANY'::text AS "entityKind", + ST_AsGeoJSON(base.geometry)::jsonb AS geometry, + base.company_code AS code,base.company_name AS name, + 'Empresa operadora'::text AS "typeName",'ACTIVE'::text AS "informationStatus", + 'POINT'::text AS "geometryType",base.updated_at AS "updatedAt", + '/inventarios/'||base.company_id::text AS href, + concat_ws(' · ','Yacimiento '||base.yacimiento_name,'Área '||base.area_name,base.department_name) AS "contextLine", + base.department_name AS "departmentName",base.area_name AS "areaName", + base.yacimiento_name AS "yacimientoName",base.company_name AS "companyName", + base.source_geometries AS "sourceGeometries" + FROM base + WHERE base.company_id IS NOT NULL + ORDER BY "entityKind","name","code" + `)) as Array>; + return { + type: 'FeatureCollection' as const, + features: rows.map((row) => ({ + type: 'Feature' as const, + id: row.featureId, + geometry: row.geometry, + properties: { + id: row.featureId, + entityId: row.entityId, + entityKind: row.entityKind, + code: row.code, + name: row.name, + typeName: row.typeName, + informationStatus: row.informationStatus, + geometryType: row.geometryType, + updatedAt: row.updatedAt, + href: row.href, + contextLine: row.contextLine, + departmentName: row.departmentName, + areaName: row.areaName, + yacimientoName: row.yacimientoName, + companyName: row.companyName, + sourceGeometries: row.sourceGeometries, + }, + })), + meta: { count: rows.length, truncated: false }, + }; + } + + async mapDocuments() { + const rows = (await this.dataSource.query(` + WITH RECURSIVE y_tree AS ( + SELECT y.id AS yacimiento_id, y.id AS asset_id + FROM assets y + INNER JOIN asset_types y_type ON y_type.id=y.asset_type_id + WHERE lower(y_type.code)='yacimiento' + AND y_type.is_active=true + AND y.information_status<>'INACTIVE' + UNION ALL + SELECT tree.yacimiento_id, child.id + FROM y_tree tree + INNER JOIN assets child ON child.parent_id=tree.asset_id + WHERE child.information_status<>'INACTIVE' + ), y_loc AS ( + SELECT tree.yacimiento_id, + ST_Centroid(ST_Collect(geometry.geometry)) AS geometry, + MAX(geometry.updated_at) AS updated_at + FROM y_tree tree + INNER JOIN asset_geometries geometry ON geometry.asset_id=tree.asset_id + GROUP BY tree.yacimiento_id + ), finding_rows AS ( + SELECT + finding.id,finding.code,finding.title,finding.status,finding.asset_id, + act.id AS act_id,act.code AS act_code,act.occurred_at, + visit.id AS visit_id,visit.code AS visit_code, + target.code AS asset_code,target.name AS asset_name, + target_geometry.geometry AS target_geometry, + tree.yacimiento_id, + y.name AS yacimiento_name,area.name AS area_name,department.name AS department_name, + COALESCE(profile.legal_name,company.name) AS company_name, + loc.geometry AS fallback_geometry,COALESCE(target_geometry.updated_at,loc.updated_at) AS updated_at + FROM inspection_findings finding + INNER JOIN inspection_acts act ON act.id=finding.act_id + INNER JOIN inspection_visits visit ON visit.id=act.visit_id + INNER JOIN assets target ON target.id=finding.asset_id + LEFT JOIN asset_geometries target_geometry ON target_geometry.asset_id=target.id + LEFT JOIN y_tree tree ON tree.asset_id=target.id + LEFT JOIN assets y ON y.id=tree.yacimiento_id + LEFT JOIN assets area ON area.id=y.parent_id + LEFT JOIN assets department ON department.id=area.parent_id + LEFT JOIN assets company ON company.id=y.operator_company_id + LEFT JOIN organization_profiles profile ON profile.asset_id=company.id + LEFT JOIN y_loc loc ON loc.yacimiento_id=tree.yacimiento_id + WHERE finding.status<>'VOIDED' + ), act_yacimiento AS ( + SELECT act.id AS act_id, + COALESCE( + CASE WHEN lower(COALESCE(scope_type.code,''))='yacimiento' THEN scope.id END, + (SELECT fr.yacimiento_id FROM finding_rows fr WHERE fr.act_id=act.id AND fr.yacimiento_id IS NOT NULL ORDER BY fr.id LIMIT 1) + ) AS yacimiento_id + FROM inspection_acts act + INNER JOIN inspection_visits visit ON visit.id=act.visit_id + LEFT JOIN assets scope ON scope.id=visit.scope_asset_id + LEFT JOIN asset_types scope_type ON scope_type.id=scope.asset_type_id + WHERE act.status<>'CANCELLED' + ) + SELECT + 'FINDING:'||finding.id::text AS "featureId",finding.id AS "entityId",'FINDING'::text AS "entityKind", + ST_AsGeoJSON(ST_Centroid(COALESCE(finding.target_geometry,finding.fallback_geometry)))::jsonb AS geometry, + finding.code,finding.title AS name,'Hallazgo'::text AS "typeName",finding.status::text AS "informationStatus", + 'POINT'::text AS "geometryType",finding.updated_at AS "updatedAt", + '/hallazgos/'||finding.id::text AS href, + concat_ws(' · ',finding.act_code,finding.asset_name,finding.yacimiento_name,finding.company_name) AS "contextLine", + finding.department_name AS "departmentName",finding.area_name AS "areaName", + finding.yacimiento_name AS "yacimientoName",finding.company_name AS "companyName", + finding.act_code AS "actCode",finding.asset_name AS "assetName" + FROM finding_rows finding + WHERE COALESCE(finding.target_geometry,finding.fallback_geometry) IS NOT NULL + UNION ALL + SELECT + 'ACT:'||act.id::text AS "featureId",act.id AS "entityId",'ACT'::text AS "entityKind", + ST_AsGeoJSON(loc.geometry)::jsonb AS geometry, + act.code,act.title AS name,'Acta'::text AS "typeName",act.status::text AS "informationStatus", + 'POINT'::text AS "geometryType",COALESCE(loc.updated_at,act.updated_at) AS "updatedAt", + '/inspecciones/actas/'||act.id::text AS href, + concat_ws(' · ',y.name,area.name,COALESCE(profile.legal_name,company.name)) AS "contextLine", + department.name AS "departmentName",area.name AS "areaName",y.name AS "yacimientoName", + COALESCE(profile.legal_name,company.name) AS "companyName",act.code AS "actCode",NULL::text AS "assetName" + FROM inspection_acts act + INNER JOIN act_yacimiento ay ON ay.act_id=act.id + INNER JOIN y_loc loc ON loc.yacimiento_id=ay.yacimiento_id + INNER JOIN assets y ON y.id=ay.yacimiento_id + LEFT JOIN assets area ON area.id=y.parent_id + LEFT JOIN assets department ON department.id=area.parent_id + LEFT JOIN assets company ON company.id=y.operator_company_id + LEFT JOIN organization_profiles profile ON profile.asset_id=company.id + WHERE act.status<>'CANCELLED' + ORDER BY "entityKind","code" + `)) as Array>; + return { + type: 'FeatureCollection' as const, + features: rows.map((row) => ({ + type: 'Feature' as const, + id: row.featureId, + geometry: row.geometry, + properties: { + id: row.featureId, + entityId: row.entityId, + entityKind: row.entityKind, + code: row.code, + name: row.name, + typeName: row.typeName, + informationStatus: row.informationStatus, + geometryType: row.geometryType, + updatedAt: row.updatedAt, + href: row.href, + contextLine: row.contextLine, + departmentName: row.departmentName, + areaName: row.areaName, + yacimientoName: row.yacimientoName, + companyName: row.companyName, + actCode: row.actCode, + assetName: row.assetName, + }, + })), + meta: { count: rows.length, truncated: false }, + }; + } + private async requireAsset( manager: EntityManager, id: string, diff --git a/api-v3/src/asset-master/asset-master.module.ts b/api-v3/src/asset-master/asset-master.module.ts index 8992fd5..0ca9008 100644 --- a/api-v3/src/asset-master/asset-master.module.ts +++ b/api-v3/src/asset-master/asset-master.module.ts @@ -7,6 +7,8 @@ import { AssetsService } from './assets.service'; import { AssetGeometriesController, MapAssetsController, + MapDocumentsController, + MapOperationalContextController, } from './asset-geometries.controller'; import { AssetGeometriesService } from './asset-geometries.service'; import { AssetHistoryController } from './asset-history.controller'; @@ -50,6 +52,8 @@ import { InventoryBrowserService } from './inventory-browser.service'; FieldInventoryMergeController, AssetGeometriesController, MapAssetsController, + MapOperationalContextController, + MapDocumentsController, AssetHistoryController, AssetMediaController, AssetProvenanceController, diff --git a/api-v3/src/inspection-acts/inspection-acts.service.ts b/api-v3/src/inspection-acts/inspection-acts.service.ts index 84dc13d..9f3aa0f 100644 --- a/api-v3/src/inspection-acts/inspection-acts.service.ts +++ b/api-v3/src/inspection-acts/inspection-acts.service.ts @@ -59,9 +59,22 @@ interface ActReportSummary { code: string; status: string; pdfStatus: string; + wordStatus: string; + gedoIfIdentifier: string | null; + gedoOfficializedAt: Date | null; generatedAt: Date; } +interface ActTerritorialContext { + department: ActContextAsset | null; + area: ActContextAsset | null; + yacimiento: ActContextAsset | null; + company: ActContextAsset | null; + installations: ActContextAsset[]; + subinstallations: ActContextAsset[]; + legacyAreaScope: boolean; +} + export interface InspectionActListItem { id: string; visitId: string; @@ -94,6 +107,7 @@ export interface InspectionActListItem { findingCount: number; companies: ActContextAsset[]; areas: ActContextAsset[]; + context: ActTerritorialContext; report: ActReportSummary | null; createdBy: ActPerson | null; updatedBy: ActPerson | null; @@ -547,11 +561,23 @@ export class InspectionActsService { COALESCE(finding_count.total, 0)::integer AS "findingCount", COALESCE(context.companies, '[]'::jsonb) AS companies, COALESCE(context.areas, '[]'::jsonb) AS areas, + JSONB_BUILD_OBJECT( + 'department', context.department, + 'area', context.area, + 'yacimiento', context.yacimiento, + 'company', context.company, + 'installations', COALESCE(context.installations, '[]'::jsonb), + 'subinstallations', COALESCE(context.subinstallations, '[]'::jsonb), + 'legacyAreaScope', context.legacy_area_scope + ) AS context, CASE WHEN report.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( 'id', report.id, 'code', report.code, 'status', report.status, 'pdfStatus', report.pdf_status, + 'wordStatus', report.word_status, + 'gedoIfIdentifier', report.gedo_if_identifier, + 'gedoOfficializedAt', report.gedo_officialized_at, 'generatedAt', report.generated_at ) END AS report, CASE WHEN creator.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( @@ -580,10 +606,64 @@ export class InspectionActsService { ) END AS companies, CASE WHEN area.id IS NULL THEN '[]'::jsonb ELSE JSONB_BUILD_ARRAY( JSONB_BUILD_OBJECT('id',area.id,'code',area.code,'name',area.name) - ) END AS areas + ) END AS areas, + CASE WHEN department.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( + 'id',department.id,'code',department.code,'name',department.name + ) END AS department, + CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( + 'id',area.id,'code',area.code,'name',area.name + ) END AS area, + CASE WHEN yacimiento.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( + 'id',yacimiento.id,'code',yacimiento.code,'name',yacimiento.name + ) END AS yacimiento, + CASE WHEN company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( + 'id',company.id,'code',company.code,'name',company.name + ) END AS company, + COALESCE(affected.installations, '[]'::jsonb) AS installations, + COALESCE(affected.subinstallations, '[]'::jsonb) AS subinstallations, + (lower(COALESCE(scope_type.code,''))='area') AS legacy_area_scope FROM inspection_visits context_visit LEFT JOIN assets company ON company.id=context_visit.operator_company_id LEFT JOIN assets area ON area.id=context_visit.operational_area_id + LEFT JOIN assets department ON department.id=area.parent_id + LEFT JOIN assets scope_asset ON scope_asset.id=context_visit.scope_asset_id + LEFT JOIN asset_types scope_type ON scope_type.id=scope_asset.asset_type_id + LEFT JOIN assets yacimiento ON yacimiento.id=CASE + WHEN lower(COALESCE(scope_type.code,''))='yacimiento' THEN scope_asset.id + ELSE NULL + END + LEFT JOIN LATERAL ( + SELECT + COALESCE(( + SELECT JSONB_AGG(JSONB_BUILD_OBJECT('id',x.id,'code',x.code,'name',x.name) ORDER BY x.name,x.code) + FROM ( + SELECT DISTINCT installation.id, installation.code, installation.name + FROM inspection_findings finding_context + INNER JOIN assets finding_asset ON finding_asset.id=finding_context.asset_id + INNER JOIN asset_types finding_type ON finding_type.id=finding_asset.asset_type_id + LEFT JOIN assets installation ON installation.id=CASE + WHEN lower(finding_type.code)='instalacion' THEN finding_asset.id + WHEN lower(finding_type.code)='subinstalacion' THEN finding_asset.parent_id + ELSE NULL + END + WHERE finding_context.act_id=act.id + AND finding_context.status<>'VOIDED' + AND installation.id IS NOT NULL + ) x + ), '[]'::jsonb) AS installations, + COALESCE(( + SELECT JSONB_AGG(JSONB_BUILD_OBJECT('id',x.id,'code',x.code,'name',x.name) ORDER BY x.name,x.code) + FROM ( + SELECT DISTINCT finding_asset.id, finding_asset.code, finding_asset.name + FROM inspection_findings finding_context + INNER JOIN assets finding_asset ON finding_asset.id=finding_context.asset_id + INNER JOIN asset_types finding_type ON finding_type.id=finding_asset.asset_type_id + WHERE finding_context.act_id=act.id + AND finding_context.status<>'VOIDED' + AND lower(finding_type.code)='subinstalacion' + ) x + ), '[]'::jsonb) AS subinstallations + ) affected ON true WHERE context_visit.id=act.visit_id ) context ON true LEFT JOIN LATERAL ( diff --git a/api-v3/src/inspection-findings/inspection-findings.service.ts b/api-v3/src/inspection-findings/inspection-findings.service.ts index af17196..fab9ebe 100644 --- a/api-v3/src/inspection-findings/inspection-findings.service.ts +++ b/api-v3/src/inspection-findings/inspection-findings.service.ts @@ -41,9 +41,18 @@ interface FindingAssetView { code: string; name: string; commonName: string | null; + typeCode: string; typeName: string; operatorCompany: FindingContextView | null; operationalArea: FindingContextView | null; + hierarchy: { + department: FindingContextView | null; + area: FindingContextView | null; + yacimiento: FindingContextView | null; + installation: FindingContextView | null; + subinstallation: FindingContextView | null; + company: FindingContextView | null; + }; } type FindingCatalogView = { @@ -809,13 +818,34 @@ export class InspectionFindingsService { 'code', asset.code, 'name', asset.name, 'commonName', asset.common_name, + 'typeCode', asset_type.code, 'typeName', asset_type.name, 'operatorCompany', CASE WHEN company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( 'id', company.id, 'code', company.code, 'name', company.name ) END, 'operationalArea', CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( 'id', area.id, 'code', area.code, 'name', area.name - ) END + ) END, + 'hierarchy', JSONB_BUILD_OBJECT( + 'department', CASE WHEN department.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( + 'id',department.id,'code',department.code,'name',department.name + ) END, + 'area', CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( + 'id',area.id,'code',area.code,'name',area.name + ) END, + 'yacimiento', CASE WHEN yacimiento.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( + 'id',yacimiento.id,'code',yacimiento.code,'name',yacimiento.name + ) END, + 'installation', CASE WHEN installation.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( + 'id',installation.id,'code',installation.code,'name',installation.name + ) END, + 'subinstallation', CASE WHEN subinstallation.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( + 'id',subinstallation.id,'code',subinstallation.code,'name',subinstallation.name + ) END, + 'company', CASE WHEN company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( + 'id',company.id,'code',company.code,'name',company.name + ) END + ) ) AS asset, CASE WHEN catalog.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( 'id', catalog.id, @@ -861,6 +891,24 @@ export class InspectionFindingsService { INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id LEFT JOIN assets company ON company.id = asset.operator_company_id LEFT JOIN assets area ON area.id = asset.operational_area_id + LEFT JOIN assets department ON department.id = area.parent_id + LEFT JOIN assets parent_asset ON parent_asset.id = asset.parent_id + LEFT JOIN assets grandparent_asset ON grandparent_asset.id = parent_asset.parent_id + LEFT JOIN assets yacimiento ON yacimiento.id = CASE + WHEN lower(asset_type.code)='yacimiento' THEN asset.id + WHEN lower(asset_type.code)='instalacion' THEN parent_asset.id + WHEN lower(asset_type.code)='subinstalacion' THEN grandparent_asset.id + ELSE NULL + END + LEFT JOIN assets installation ON installation.id = CASE + WHEN lower(asset_type.code)='instalacion' THEN asset.id + WHEN lower(asset_type.code)='subinstalacion' THEN parent_asset.id + ELSE NULL + END + LEFT JOIN assets subinstallation ON subinstallation.id = CASE + WHEN lower(asset_type.code)='subinstalacion' THEN asset.id + ELSE NULL + END LEFT JOIN finding_catalog_items catalog ON catalog.id = finding.catalog_item_id LEFT JOIN finding_categories category ON category.id = catalog.category_id LEFT JOIN LATERAL ( diff --git a/api-v3/src/version.ts b/api-v3/src/version.ts index 67e08e4..b8c7f05 100644 --- a/api-v3/src/version.ts +++ b/api-v3/src/version.ts @@ -1,2 +1,2 @@ -export const API_VERSION = '0.29.0-14'; -export const API_PHASE = 'F6.14'; +export const API_VERSION = '0.29.0-15'; +export const API_PHASE = 'F6.15'; diff --git a/api-v3/test/unit/f4-health-metadata.test.ts b/api-v3/test/unit/f4-health-metadata.test.ts index ad42ed1..158a56f 100644 --- a/api-v3/test/unit/f4-health-metadata.test.ts +++ b/api-v3/test/unit/f4-health-metadata.test.ts @@ -4,9 +4,9 @@ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { API_PHASE, API_VERSION } from '../../src/version'; -test('health metadata reports the current F6.14 release', () => { - assert.equal(API_PHASE, 'F6.14'); +test('health metadata reports the current F6.15 release', () => { + assert.equal(API_PHASE, 'F6.15'); const pkg = JSON.parse(readFileSync(resolve(process.cwd(), 'package.json'), 'utf8')) as { version: string }; assert.equal(API_VERSION, pkg.version); - assert.equal(API_VERSION, '0.29.0-14'); + assert.equal(API_VERSION, '0.29.0-15'); }); diff --git a/api-v3/test/unit/f6-1-presentation-ready-contract.test.ts b/api-v3/test/unit/f6-1-presentation-ready-contract.test.ts index df20c1b..b341592 100644 --- a/api-v3/test/unit/f6-1-presentation-ready-contract.test.ts +++ b/api-v3/test/unit/f6-1-presentation-ready-contract.test.ts @@ -13,7 +13,7 @@ test('F6.1 presentation metadata keeps the visible WEB version aligned with pack const visibleVersion = version.match(/APP_VERSION\s*=\s*'([^']+)'/)?.[1]; assert.equal(visibleVersion, pkg.version); - assert.match(version, /APP_PHASE\s*=\s*'F6\.13/); + assert.match(version, /APP_PHASE\s*=\s*'F6\.15/); }); test('F6.1 presentation keeps Relevamientos retired from WEB navigation and routes', () => { diff --git a/api-v3/test/unit/f6-15-act-map-context.test.ts b/api-v3/test/unit/f6-15-act-map-context.test.ts new file mode 100644 index 0000000..0ab14d3 --- /dev/null +++ b/api-v3/test/unit/f6-15-act-map-context.test.ts @@ -0,0 +1,60 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import test from 'node:test'; + +function api(path: string) { return readFileSync(resolve(process.cwd(), path), 'utf8'); } +function web(path: string) { return readFileSync(resolve(process.cwd(), `../web-v2/src/${path}`), 'utf8'); } + +test('F6.15 Acta exposes full territorial and affected-inventory context', () => { + const acts = api('src/inspection-acts/inspection-acts.service.ts'); + const findings = api('src/inspection-findings/inspection-findings.service.ts'); + const page = web('pages/InspectionActEditorPage.tsx'); + const media = web('features/inspections/InspectionActMediaPanel.tsx'); + for (const field of ['department', 'area', 'yacimiento', 'company', 'installations', 'subinstallations']) { + assert.match(acts, new RegExp(`'${field}'`)); + } + assert.match(acts, /legacyAreaScope/); + assert.match(findings, /'hierarchy'/); + assert.match(findings, /'installation'/); + assert.match(findings, /'subinstallation'/); + assert.match(page, /Ubicación territorial y operativa/); + assert.match(page, /No definido en la Inspección histórica/); + assert.match(media, /Elemento afectado:/); + assert.match(media, /Constatación:/); +}); + +test('F6.15 Acta uses real report workflow state instead of legacy generated PDF status', () => { + const acts = api('src/inspection-acts/inspection-acts.service.ts'); + const page = web('pages/InspectionActEditorPage.tsx'); + const list = web('pages/ActsPage.tsx'); + assert.match(acts, /'gedoIfIdentifier'/); + assert.match(acts, /'gedoOfficializedAt'/); + assert.match(acts, /'wordStatus'/); + assert.match(page, /act\.report\.status === 'OFFICIALIZED'/); + assert.match(page, /Oficializado en GEDO/); + assert.doesNotMatch(page, /act\.report\.pdfStatus === 'READY'/); + assert.match(list, /act\.report\.status === 'OFFICIALIZED'/); +}); + +test('F6.15 map exposes filtered operational layers without inventing coordinates', () => { + const controller = api('src/asset-master/asset-geometries.controller.ts'); + const service = api('src/asset-master/asset-geometries.service.ts'); + const page = web('pages/MapPage.tsx'); + const map = web('features/map/DhMap.tsx'); + assert.match(controller, /@Controller\('map\/context'\)/); + assert.match(controller, /@Controller\('map\/documents'\)/); + assert.match(service, /WITH RECURSIVE y_tree/); + assert.match(service, /ST_Centroid\(ST_Collect\(geometry\.geometry\)\)/); + assert.match(service, /COALESCE\(finding\.target_geometry,finding\.fallback_geometry\)/); + for (const kind of ['YACIMIENTO', 'COMPANY', 'ACT', 'FINDING']) { + assert.match(service, new RegExp(`'${kind}'::text`)); + assert.match(page, new RegExp(`${kind}:`)); + } + assert.match(page, /Buscar Yacimiento, Empresa, Acta, Hallazgo/); + assert.match(page, /sourceGeometries/); + assert.match(map, /yacimiento-points/); + assert.match(map, /company-points/); + assert.match(map, /act-points/); + assert.match(map, /finding-points/); +}); diff --git a/docs/PHASE_F6_15_WEB_ACT_MAP.md b/docs/PHASE_F6_15_WEB_ACT_MAP.md new file mode 100644 index 0000000..98072c4 --- /dev/null +++ b/docs/PHASE_F6_15_WEB_ACT_MAP.md @@ -0,0 +1,23 @@ +# F6.15 · Contexto documental y mapa operativo + +## Actas + +- El detalle de Acta expone Departamento, Área, Yacimiento y Empresa/Operadora. +- Las Instalaciones y Subinstalaciones mostradas son únicamente las que tienen Hallazgos en esa Acta. +- Cada Hallazgo explica el elemento afectado y su ruta territorial/técnica completa. +- Las Inspecciones históricas cuyo alcance quedó guardado como Área se identifican como tales; el sistema no inventa un Yacimiento. + +## Informe relacionado + +- El estado visible del Informe se obtiene del workflow documental real. +- `OFFICIALIZED` se presenta como oficializado en GEDO y muestra el identificador IF cuando existe. +- `wordStatus=READY` indica que el INF está listo para remitir a GEDO; `pdfStatus` ya no determina si el Informe está "en preparación". + +## Mapa + +- Capas independientes: Inventario GPS, Yacimientos, Empresas, Actas y Hallazgos. +- Incluye buscador transversal y conserva filtros de Inventario por tipo, estado y geometría. +- Un Yacimiento sin geometría propia sólo se ubica cuando alguna Instalación/Subinstalación descendiente tiene geometría real; se utiliza el centroide de las geometrías registradas. +- La Empresa se representa como presencia operativa en el Yacimiento que opera, no como una sede inventada. +- Hallazgos usan la geometría exacta del elemento afectado cuando existe y, como respaldo, la ubicación derivada del Yacimiento. +- Actas usan la ubicación derivada de su Yacimiento. Los registros sin referencia geográfica real no se dibujan. diff --git a/web-v2/package-lock.json b/web-v2/package-lock.json index e3f0f29..1c5e05c 100644 --- a/web-v2/package-lock.json +++ b/web-v2/package-lock.json @@ -1,12 +1,12 @@ { "name": "dhv2-web", - "version": "0.23.0-10", + "version": "0.23.0-11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dhv2-web", - "version": "0.23.0-10", + "version": "0.23.0-11", "dependencies": { "maplibre-gl": "6.4.1", "react": "^19.0.0", diff --git a/web-v2/package.json b/web-v2/package.json index b1e46e2..14fb4ae 100644 --- a/web-v2/package.json +++ b/web-v2/package.json @@ -1,6 +1,6 @@ { "name": "dhv2-web", - "version": "0.23.0-10", + "version": "0.23.0-11", "private": true, "type": "module", "engines": { diff --git a/web-v2/src/config/version.ts b/web-v2/src/config/version.ts index bea7ab9..0385aae 100644 --- a/web-v2/src/config/version.ts +++ b/web-v2/src/config/version.ts @@ -1,2 +1,2 @@ -export const APP_VERSION = '0.23.0-10'; -export const APP_PHASE = 'F6.13 · Mapa operativo'; +export const APP_VERSION = '0.23.0-11'; +export const APP_PHASE = 'F6.15 · Actas y mapa operativo'; diff --git a/web-v2/src/features/inspections/InspectionActMediaPanel.tsx b/web-v2/src/features/inspections/InspectionActMediaPanel.tsx index cb7dbd7..07ac0af 100644 --- a/web-v2/src/features/inspections/InspectionActMediaPanel.tsx +++ b/web-v2/src/features/inspections/InspectionActMediaPanel.tsx @@ -30,9 +30,18 @@ function Photo({ id, title, caption, load }: { id: string; title: string; captio function Finding({ item }: { item: FindingWithPhotos }) { const { finding, photos } = item; + const hierarchy = finding.asset.hierarchy; return
-
{finding.code}

{finding.title}

{finding.asset.name} · {finding.asset.code}

-

{finding.description}

+
{finding.code}

{finding.title}

Elemento afectado: {finding.asset.typeName} · {finding.asset.name} · {finding.asset.code}

+ {hierarchy &&
+
Departamento{hierarchy.department?.name ?? '—'}
+
Área{hierarchy.area?.name ?? '—'}
+
Yacimiento{hierarchy.yacimiento?.name ?? '—'}
+
Empresa{hierarchy.company?.name ?? '—'}
+
Instalación{hierarchy.installation?.name ?? 'No corresponde'}
+
Subinstalación{hierarchy.subinstallation?.name ?? 'No corresponde'}
+
} +

Constatación: {finding.description}

{finding.legalBasis &&

Normativa: {finding.legalBasis}

} {finding.severity != null &&

Gravedad {finding.severity}/10

} {photos.length > 0 &&
diff --git a/web-v2/src/features/map/DhMap.tsx b/web-v2/src/features/map/DhMap.tsx index 5248abc..3f39c28 100644 --- a/web-v2/src/features/map/DhMap.tsx +++ b/web-v2/src/features/map/DhMap.tsx @@ -20,7 +20,7 @@ const osmStyle = { layers: [{ id: 'osm', type: 'raster' as const, source: 'osm' }], }; -const interactiveLayers = ['assets-points', 'assets-lines', 'assets-polygons']; +const interactiveLayers = ['assets-points', 'yacimiento-points', 'company-points', 'act-points', 'finding-points', 'assets-lines', 'assets-polygons']; function boundsFromFeatures(collection: MapAssetFeatureCollection) { const positions: Array<[number, number]> = []; @@ -79,11 +79,28 @@ export function DhMap({ }); map.addLayer({ id: 'assets-points', type: 'circle', source: 'assets', - filter: ['==', ['geometry-type'], 'Point'], - paint: { - 'circle-radius': 7, 'circle-color': '#2864dc', - 'circle-stroke-color': '#ffffff', 'circle-stroke-width': 2, - }, + filter: ['all', ['==', ['geometry-type'], 'Point'], ['==', ['get', 'entityKind'], 'ASSET']], + paint: { 'circle-radius': 7, 'circle-color': '#64748b', 'circle-stroke-color': '#ffffff', 'circle-stroke-width': 2 }, + }); + map.addLayer({ + id: 'yacimiento-points', type: 'circle', source: 'assets', + filter: ['all', ['==', ['geometry-type'], 'Point'], ['==', ['get', 'entityKind'], 'YACIMIENTO']], + paint: { 'circle-radius': 9, 'circle-color': '#2563eb', 'circle-stroke-color': '#ffffff', 'circle-stroke-width': 2 }, + }); + map.addLayer({ + id: 'company-points', type: 'circle', source: 'assets', + filter: ['all', ['==', ['geometry-type'], 'Point'], ['==', ['get', 'entityKind'], 'COMPANY']], + paint: { 'circle-radius': 8, 'circle-color': '#059669', 'circle-stroke-color': '#ffffff', 'circle-stroke-width': 2 }, + }); + map.addLayer({ + id: 'act-points', type: 'circle', source: 'assets', + filter: ['all', ['==', ['geometry-type'], 'Point'], ['==', ['get', 'entityKind'], 'ACT']], + paint: { 'circle-radius': 9, 'circle-color': '#7c3aed', 'circle-stroke-color': '#ffffff', 'circle-stroke-width': 2 }, + }); + map.addLayer({ + id: 'finding-points', type: 'circle', source: 'assets', + filter: ['all', ['==', ['geometry-type'], 'Point'], ['==', ['get', 'entityKind'], 'FINDING']], + paint: { 'circle-radius': 8, 'circle-color': '#dc2626', 'circle-stroke-color': '#ffffff', 'circle-stroke-width': 2 }, }); map.addLayer({ id: 'assets-selected-polygons', type: 'line', source: 'assets', diff --git a/web-v2/src/lib/api.ts b/web-v2/src/lib/api.ts index 433864a..f5a8b30 100644 --- a/web-v2/src/lib/api.ts +++ b/web-v2/src/lib/api.ts @@ -553,21 +553,34 @@ export interface AssetGeometry { updatedBy: string | null; } +export type MapEntityKind = 'ASSET' | 'YACIMIENTO' | 'COMPANY' | 'ACT' | 'FINDING'; + export interface MapAssetProperties { id: string; + entityId?: string; + entityKind?: MapEntityKind; code: string; name: string; commonName?: string | null; - typeId: string; - typeCode: string; + typeId?: string | null; + typeCode?: string | null; typeName: string; - parentId: string | null; - parentName: string | null; - informationStatus: AssetInformationStatus; + parentId?: string | null; + parentName?: string | null; + informationStatus?: AssetInformationStatus | null; geometryType: AssetGeometryType; - accuracyM: number | null; - capturedAt: string | null; + accuracyM?: number | null; + capturedAt?: string | null; updatedAt: string; + href?: string | null; + contextLine?: string | null; + departmentName?: string | null; + areaName?: string | null; + yacimientoName?: string | null; + companyName?: string | null; + actCode?: string | null; + assetName?: string | null; + sourceGeometries?: number | null; } export interface MapAssetFeature { @@ -1473,7 +1486,17 @@ export interface InspectionFinding { currentVersion: number; closedAt: string | null; closureNotes: string | null; - asset: InspectionAssetSummary; + asset: InspectionAssetSummary & { + typeCode?: string; + hierarchy?: { + department: { id: string; code: string; name: string } | null; + area: { id: string; code: string; name: string } | null; + yacimiento: { id: string; code: string; name: string } | null; + installation: { id: string; code: string; name: string } | null; + subinstallation: { id: string; code: string; name: string } | null; + company: { id: string; code: string; name: string } | null; + }; + }; catalog: { id: string; code: string; @@ -2188,6 +2211,14 @@ export function removeAssetGeometry(assetId: string) { }); } +export function getMapOperationalContext() { + return apiRequest('/map/context'); +} + +export function getMapDocuments() { + return apiRequest('/map/documents'); +} + export function getMapAssets(params: { bbox?: string; typeId?: string; diff --git a/web-v2/src/lib/inspectionActF4Api.ts b/web-v2/src/lib/inspectionActF4Api.ts index 3cb66c0..fe3c6de 100644 --- a/web-v2/src/lib/inspectionActF4Api.ts +++ b/web-v2/src/lib/inspectionActF4Api.ts @@ -73,11 +73,23 @@ export interface InspectionActListItemF4 { findingCount: number; companies: Array<{ id: string; code: string; name: string }>; areas: Array<{ id: string; code: string; name: string }>; + context: { + department: { id: string; code: string; name: string } | null; + area: { id: string; code: string; name: string } | null; + yacimiento: { id: string; code: string; name: string } | null; + company: { id: string; code: string; name: string } | null; + installations: Array<{ id: string; code: string; name: string }>; + subinstallations: Array<{ id: string; code: string; name: string }>; + legacyAreaScope: boolean; + }; report: null | { id: string; code: string; status: InspectionReportStatusF4; pdfStatus: InspectionReportPdfStatus; + wordStatus: 'PENDING' | 'READY' | 'FAILED'; + gedoIfIdentifier: string | null; + gedoOfficializedAt: string | null; generatedAt: string; }; createdBy: InspectionPerson | null; diff --git a/web-v2/src/pages/ActsPage.tsx b/web-v2/src/pages/ActsPage.tsx index 2ea08dc..813b6fc 100644 --- a/web-v2/src/pages/ActsPage.tsx +++ b/web-v2/src/pages/ActsPage.tsx @@ -111,13 +111,13 @@ export function ActsPage() { {error && {error}} {loading ? : items.length === 0 ? :
{meta.total} acta{meta.total === 1 ? '' : 's'}Página {page} de {Math.max(meta.totalPages, 1)}
-
{items.map((act) => +
ActaEmpresa / áreaInspecciónHallazgosEstadoInforme
{items.map((act) => - + - + )}
ActaEmpresa / territorioInspecciónHallazgosEstadoInforme
{act.code}{formatDate(act.occurredAt)}
{contextLabel(act.companies, 'Empresa sin asignar')}{contextLabel(act.areas, 'Área sin asignar')}
{act.context.company?.name ?? contextLabel(act.companies, 'Empresa sin asignar')}{[act.context.department?.name, act.context.area?.name, act.context.yacimiento?.name].filter(Boolean).join(' · ') || contextLabel(act.areas, 'Área sin asignar')}
{act.visit.code} {act.findingCount} {inspectionActStatusLabel(act.status)}{act.report ? {act.report.code}{act.report.pdfStatus === 'READY' ? 'PDF disponible' : 'PDF pendiente'} : ['SEALED', 'CLOSED'].includes(act.status) ? Pendiente de emisión : }{act.report ? {act.report.code}{act.report.status === 'OFFICIALIZED' ? `Oficializado en GEDO${act.report.gedoIfIdentifier ? ` · ${act.report.gedoIfIdentifier}` : ''}` : act.report.wordStatus === 'READY' ? 'INF listo' : 'En preparación'} : ['SEALED', 'CLOSED'].includes(act.status) ? Pendiente de emisión : }
{meta.total ? `${(page - 1) * meta.pageSize + 1}–${Math.min(page * meta.pageSize, meta.total)} de ${meta.total}` : '0 resultados'}
diff --git a/web-v2/src/pages/InspectionActEditorPage.tsx b/web-v2/src/pages/InspectionActEditorPage.tsx index f826de1..4f993dc 100644 --- a/web-v2/src/pages/InspectionActEditorPage.tsx +++ b/web-v2/src/pages/InspectionActEditorPage.tsx @@ -80,10 +80,23 @@ export function InspectionActEditorPage() {
DOCUMENTO DEL ACTA

{isSealed ? 'Acta consolidada disponible' : 'Acta en preparación'}

{isSealed ? 'Abrí o descargá el Acta firmada, con sus Hallazgos y constancias de integridad.' : 'El documento definitivo se genera al firmar y cerrar el Acta.'}

{isSealed ? <> : {inspectionActStatusLabel(act.status)}}
} - {act?.report &&

Informe relacionado: {act.report.code}. {act.report.pdfStatus === 'READY' ? ' Disponible.' : ' En preparación.'}

} + {act?.report &&

Informe relacionado: {act.report.code}. {act.report.status === 'OFFICIALIZED' ? ` Oficializado en GEDO${act.report.gedoIfIdentifier ? ` · ${act.report.gedoIfIdentifier}` : ''}.` : act.report.status === 'FROZEN' ? ' Informe consolidado.' : act.report.wordStatus === 'READY' ? ' INF listo para enviar a GEDO.' : ' En preparación.'}

} {isSealed && act && !act.report &&

INF pendiente de emisión. El Acta ya está sellada y disponible como documento fuente.

} {act?.status === 'CANCELLED' && Cancelada: {act.cancellationReason}} + {act &&
+
CONTEXTO DEL ACTA

Ubicación territorial y operativa

El Acta corresponde a un Yacimiento y los Hallazgos se ubican dentro de sus Instalaciones y Subinstalaciones.

+
+
Departamento{act.context.department?.name ?? 'Sin definir'}{act.context.department && {act.context.department.code}}
+
Área{act.context.area?.name ?? 'Sin definir'}{act.context.area && {act.context.area.code}}
+
Yacimiento{act.context.yacimiento?.name ?? 'No definido en la Inspección histórica'}{act.context.yacimiento && {act.context.yacimiento.code}}
+
Empresa / Operadora{act.context.company?.name ?? 'Sin definir'}{act.context.company && {act.context.company.code}}
+
Instalaciones con Hallazgos{act.context.installations.length ? act.context.installations.map((item) => item.name).join(' · ') : 'Ninguna'}
+
Subinstalaciones con Hallazgos{act.context.subinstallations.length ? act.context.subinstallations.map((item) => item.name).join(' · ') : 'Ninguna'}
+
+ {act.context.legacyAreaScope && !act.context.yacimiento && Esta Acta pertenece a una Inspección histórica creada antes de exigir Yacimiento como alcance. El Área se conserva como fue registrada; no se la presenta como Yacimiento.} +
} + {act &&
Fecha de inspección{formatDate(act.occurredAt)}
diff --git a/web-v2/src/pages/MapPage.tsx b/web-v2/src/pages/MapPage.tsx index 7214cdf..a9cbcdb 100644 --- a/web-v2/src/pages/MapPage.tsx +++ b/web-v2/src/pages/MapPage.tsx @@ -1,74 +1,96 @@ import { SearchableSelect } from '../components/SearchableSelect'; import { useEffect, useMemo, useState } from 'react'; import { Link } from 'react-router'; +import { useAuth } from '../auth/AuthContext'; import { Alert, LoadingBlock, errorMessage } from '../components/Feedback'; import { Icon } from '../components/Icon'; import { AssetCenterTabs } from '../features/assets/AssetCenterTabs'; import { DhMap } from '../features/map/DhMap'; -import { - assetStatusClass, - assetStatusLabel, - ASSET_STATUSES, -} from '../features/assets/assetPresentation'; -import { getMapAssets, listAssetTypes } from '../lib/api'; -import type { - AssetGeometryType, - AssetInformationStatus, - AssetType, - MapAssetFeatureCollection, -} from '../lib/api'; +import { assetStatusClass, assetStatusLabel, ASSET_STATUSES } from '../features/assets/assetPresentation'; +import { getMapAssets, getMapDocuments, getMapOperationalContext, listAssetTypes } from '../lib/api'; +import type { AssetGeometryType, AssetInformationStatus, AssetType, MapAssetFeatureCollection, MapEntityKind } from '../lib/api'; import { formatDate } from '../lib/format'; -const emptyCollection: MapAssetFeatureCollection = { - type: 'FeatureCollection', features: [], meta: { count: 0, truncated: false }, +const emptyCollection: MapAssetFeatureCollection = { type: 'FeatureCollection', features: [], meta: { count: 0, truncated: false } }; +const layerLabels: Record = { + ASSET: 'Inventario GPS', YACIMIENTO: 'Yacimientos', COMPANY: 'Empresas', ACT: 'Actas', FINDING: 'Hallazgos', }; export function MapPage() { + const { hasPermission } = useAuth(); + const canReadDocuments = hasPermission('inspection_acts.read') && hasPermission('inspection_findings.read'); const [types, setTypes] = useState([]); - const [data, setData] = useState(emptyCollection); + const [assets, setAssets] = useState(emptyCollection); + const [context, setContext] = useState(emptyCollection); + const [documents, setDocuments] = useState(emptyCollection); const [typeId, setTypeId] = useState(''); const [status, setStatus] = useState(''); const [geometryType, setGeometryType] = useState(''); + const [mapSearch, setMapSearch] = useState(''); + const [layers, setLayers] = useState>({ ASSET: true, YACIMIENTO: true, COMPANY: true, ACT: true, FINDING: true }); const [selectedId, setSelectedId] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); - useEffect(() => { - listAssetTypes().then(setTypes).catch(() => undefined); - }, []); - + useEffect(() => { listAssetTypes().then(setTypes).catch(() => undefined); }, []); useEffect(() => { setLoading(true); setError(''); - getMapAssets({ typeId, status, geometryType }) - .then((result) => { - setData(result); - setSelectedId((current) => result.features.some((item) => item.id === current) ? current : null); - }) - .catch((requestError) => setError(errorMessage(requestError))) - .finally(() => setLoading(false)); - }, [typeId, status, geometryType]); + Promise.all([ + getMapAssets({ typeId, status, geometryType }), + getMapOperationalContext(), + canReadDocuments ? getMapDocuments() : Promise.resolve(emptyCollection), + ]).then(([assetResult, contextResult, documentResult]) => { + setAssets(assetResult); setContext(contextResult); setDocuments(documentResult); + }).catch((requestError) => setError(errorMessage(requestError))).finally(() => setLoading(false)); + }, [typeId, status, geometryType, canReadDocuments]); - const selected = useMemo( - () => data.features.find((feature) => feature.id === selectedId) ?? null, - [data, selectedId], - ); + const data = useMemo(() => { + const directAssets = assets.features.filter((feature) => !['yacimiento', 'empresa'].includes(feature.properties.typeCode?.toLowerCase() ?? '')); + const all = [...directAssets, ...context.features, ...documents.features]; + const term = mapSearch.trim().toLocaleLowerCase('es-AR'); + const features = all.filter((feature) => { + if (!layers[feature.properties.entityKind ?? 'ASSET']) return false; + if (!term) return true; + const searchable = [ + feature.properties.code, feature.properties.name, feature.properties.typeName, + feature.properties.contextLine, feature.properties.departmentName, feature.properties.areaName, + feature.properties.yacimientoName, feature.properties.companyName, feature.properties.actCode, + feature.properties.assetName, + ].filter(Boolean).join(' ').toLocaleLowerCase('es-AR'); + return searchable.includes(term); + }); + return { type: 'FeatureCollection', features, meta: { count: features.length, truncated: assets.meta.truncated } }; + }, [assets, context, documents, layers, mapSearch]); + + useEffect(() => { + setSelectedId((current) => data.features.some((item) => item.id === current) ? current : null); + }, [data]); + + const selected = useMemo(() => data.features.find((feature) => feature.id === selectedId) ?? null, [data, selectedId]); + const toggleLayer = (kind: MapEntityKind) => setLayers((current) => ({ ...current, [kind]: !current[kind] })); + const kind = selected?.properties.entityKind ?? 'ASSET'; return
-
INVENTARIOS

Mapa de inventarios

Vista territorial de las ubicaciones registradas.

{data.meta.count} geometría{data.meta.count === 1 ? '' : 's'}
+
TERRITORIO Y OPERACIÓN

Mapa operativo

Yacimientos, presencia de Empresas, Actas, Hallazgos e Inventario con ubicación real o derivada de geometrías registradas.

{data.meta.count} elemento{data.meta.count === 1 ? '' : 's'}
{error && {error}} - {data.meta.truncated && Se muestran los primeros 5000 registros. Aplicá filtros para reducir el resultado.} + {data.meta.truncated && Se muestran los primeros 5000 registros de Inventario. Aplicá filtros para reducir el resultado.}
-
{loading &&
}{!loading && data.features.length === 0 &&
No hay geometrías para mostrarAgregá una ubicación desde el detalle de un registro.
}
+
{loading &&
}{!loading && data.features.length === 0 &&
No hay ubicaciones para mostrarLas capas sólo muestran registros con una geometría real propia o derivable de su Yacimiento.
}
; } diff --git a/web-v2/src/styles.css b/web-v2/src/styles.css index 2cd4159..c9ef881 100644 --- a/web-v2/src/styles.css +++ b/web-v2/src/styles.css @@ -1436,3 +1436,9 @@ code { color: #5e6677; font-family: ui-monospace, monospace; font-size: 9px; } .act-other-asset-photos { border-top: 1px solid #e3eaf5; margin-top: 24px; padding-top: 20px; } .act-other-asset-photos h3 { margin: 0 0 14px; } + +/* F6.15 · capas del mapa operativo */ +.map-layer-buttons { display: flex; flex-wrap: wrap; gap: 6px; } +.map-layer-buttons .button { flex: 1 1 calc(50% - 6px); justify-content: center; min-width: 104px; } +.map-global-search { width: 100%; margin: 0; } +.act-finding-context { margin: 10px 0 12px; }