Compare commits

..
Author SHA1 Message Date
admin cedcbac678 test(F6.1): lock Android Yacimiento root contract 2026-09-10 15:55:52 -03:00
admin 610e011814 test(F6.1): supersede Area-wide field scope expectation 2026-09-10 15:55:34 -03:00
github-actions[bot] 20c0ef92e7 fix(android): root field inventory at inspection Yacimiento 2026-09-10 18:54:47 +00:00
admin d2b08c8687 chore(F6.1): apply Android Yacimiento root fix 2026-09-10 15:54:38 -03:00
admin 3a08972cc3 test(F6.1): lock Yacimiento scope review fixes 2026-09-10 15:50:12 -03:00
github-actions[bot] 736d09b849 fix(F6.1): validate planned context without snapshot ownership 2026-09-10 18:49:35 +00:00
admin ed4a3d5a44 chore(F6.1): finish planned scope consistency 2026-09-10 15:49:25 -03:00
github-actions[bot] b1f0064b40 fix(F6.1): enforce Yacimiento scope end to end 2026-09-10 18:45:42 +00:00
admin f99452c21c chore(F6.1): apply reviewed inspection scope fixes 2026-09-10 15:45:28 -03:00
admin 0df6ec19d6 fix(F6.1): resolve operators at planned inspection date 2026-09-10 15:43:05 -03:00
admin e4153ce3ec test(android): align F6.1 presentation version 0.15.2 2026-09-10 15:42:47 -03:00
admin dd0c9f461b feat(android): use exact Mendoza presentation launcher icon 2026-09-10 15:28:18 -03:00
admin ea6a9fb836 test(F6.1): lock read-only checklist and preparation stage 2026-09-10 13:30:18 -03:00
admin c2735d8019 fix(F6.1): make field preparation an inspection detail action 2026-09-10 13:29:12 -03:00
admin 865e66fd49 fix(F6.1): surface field preparation inside inspection detail 2026-09-10 13:28:43 -03:00
admin 746592f896 fix(F6.1): make F4 checklist classification read-only 2026-09-10 13:26:16 -03:00
admin 2710c48b58 fix(F6.1): log exact inspection creation failure stage 2026-09-10 13:21:44 -03:00
admin e3772a4006 ci(F6.1): run hardened inspection smoke script 2026-09-10 13:17:06 -03:00
admin 844aa7e27f ci(F6.1): harden real inspection creation smoke 2026-09-10 13:16:56 -03:00
admin fbb2ebcb72 ci(F6.1): create a real inspection after territory preload 2026-09-10 13:14:30 -03:00
admin d825bd2cd5 test(F6.1): lock inspection hierarchy and field preparation UX 2026-09-10 13:07:58 -03:00
admin 75dea3c31b chore: remove temporary inspection planning patch workflow 2026-09-10 13:07:22 -03:00
admin 7c8085f237 fix(F6.1): route new inspections to hierarchical planner 2026-09-10 13:07:10 -03:00
admin 82fb0e191b fix(F6.1): add explicit hierarchical inspection creation page 2026-09-10 13:06:47 -03:00
admin 0e71a56baa fix(F6.1): register hierarchy-aware inspection planning services 2026-09-10 13:06:03 -03:00
admin f7a137d6ea fix(F6.1): route web inspection create through hierarchy-aware service 2026-09-10 13:05:31 -03:00
admin 3f43f5f9fb fix(F6.1): add hierarchy-aware web inspection creation 2026-09-10 13:04:58 -03:00
admin 5b6f905778 chore: prepare deterministic F6.1 inspection planning patch 2026-09-10 13:03:46 -03:00
admin aaa5765120 fix(F6.1): make field preparation inspection-scoped 2026-09-10 13:02:52 -03:00
admin 302199a554 fix(F6.1): keep field preparation inside inspections 2026-09-10 13:02:15 -03:00
admin 5a656f8604 fix(F6.1): expose Departamento Area Yacimiento planning routes 2026-09-10 13:01:20 -03:00
admin 89a7a24bad fix(F6.1): accept explicit yacimiento scope on inspection create 2026-09-10 13:00:54 -03:00
admin f8ddd7263a fix(F6.1): validate yacimiento inspection scope 2026-09-10 13:00:39 -03:00
admin 2060d81732 fix(F6.1): expose explicit inspection planning hierarchy 2026-09-10 13:00:27 -03:00
87 changed files with 759 additions and 7208 deletions
+25 -100
View File
@@ -116,10 +116,9 @@ jobs:
docker compose --env-file .env.example --profile tools run --rm migrate
# The completed chain must end in the exact authoritative SQL model:
# 7 Departamentos, 64 Áreas, 230 Yacimientos, 13 Empresas, 2 Tipos de
# concesión, 14/109 technical families, 181 Hallazgos and their exact
# 48 + 880 contextual mappings.
# F5.1 performs the historical clean start. F6.1 then seeds only the
# presentation territory approved by DH: 7 Departamentos, 64 Áreas,
# 230 Yacimientos and 12 real Empresas.
docker compose --env-file .env.example exec -T db \
psql -v ON_ERROR_STOP=1 -U dhv2_owner -d dhv2 <<'SQL'
DO $$
@@ -128,23 +127,13 @@ jobs:
domain_assets integer;
audits integer;
applicability integer;
legacy_territory_sources integer;
legacy_presentation_sources integer;
authoritative_sources integer;
territory_sources integer;
presentation_sources integer;
source_installations integer;
source_subinstallations integer;
source_findings integer;
concession_types integer;
departments integer;
areas integer;
yacimientos integer;
companies integer;
invalid_yacimientos integer;
department_types integer;
area_department_rules integer;
yacimiento_area_rules integer;
installation_yacimiento_rules integer;
subinstallation_installation_rules integer;
BEGIN
SELECT COUNT(*) INTO f5_migrations
FROM typeorm_migrations
@@ -161,86 +150,49 @@ jobs:
END IF;
SELECT COUNT(*) INTO domain_assets FROM assets;
IF domain_assets <> 314 THEN
RAISE EXCEPTION 'Authoritative model must contain 314 Assets, got %', domain_assets;
IF domain_assets <> 313 THEN
RAISE EXCEPTION 'F6.1 presentation preload must contain 313 Assets, got %', domain_assets;
END IF;
SELECT COUNT(*) INTO audits FROM audit_events;
IF audits <> 0 THEN
RAISE EXCEPTION 'Clean authoritative rehearsal must start with 0 audit events, got %', audits;
RAISE EXCEPTION 'F6.1 presentation preload must start with 0 audit events, got %', audits;
END IF;
SELECT COUNT(*) INTO applicability FROM finding_catalog_item_inventory_families;
IF applicability <> 928 THEN
RAISE EXCEPTION 'Authoritative model must contain 928 finding applicability links, got %', applicability;
IF applicability <> 0 THEN
RAISE EXCEPTION 'Clean migration rehearsal must contain 0 finding applicability links, got %', applicability;
END IF;
SELECT COUNT(*) INTO legacy_territory_sources
SELECT COUNT(*) INTO territory_sources
FROM source_documents
WHERE document_number='DH-F5-TERRITORY';
IF legacy_territory_sources <> 0 THEN
RAISE EXCEPTION 'Authoritative model must remove the old F5 territory source, got % rows', legacy_territory_sources;
IF territory_sources <> 0 THEN
RAISE EXCEPTION 'F5.1 must remove the old territory source preload, got % rows', territory_sources;
END IF;
SELECT COUNT(*) INTO legacy_presentation_sources
SELECT COUNT(*) INTO presentation_sources
FROM source_documents
WHERE document_number='DH-F6.1-PRESENTATION-TERRITORY-20260909';
IF legacy_presentation_sources <> 0 THEN
RAISE EXCEPTION 'Authoritative model must remove the F6.1 presentation source, got % rows', legacy_presentation_sources;
END IF;
SELECT COUNT(*) INTO authoritative_sources
FROM source_documents
WHERE document_number IN ('DH-AUTH-TERRITORY-20260911','DH-AUTH-INVENTORY-20260911');
IF authoritative_sources <> 2 THEN
RAISE EXCEPTION 'Authoritative model must contain its two SQL source documents, got %', authoritative_sources;
IF presentation_sources <> 1 THEN
RAISE EXCEPTION 'F6.1 must contain exactly one presentation territory source, got %', presentation_sources;
END IF;
SELECT COUNT(*) FILTER (WHERE level='INSTALLATION'),
COUNT(*) FILTER (WHERE level='SUBINSTALLATION')
INTO source_installations,source_subinstallations
FROM inventory_families
WHERE is_active=true AND source_reference LIKE 'modelo_relacional:%';
WHERE is_active=true AND source_reference LIKE 'F5:final_modelov2.xlsx%';
IF source_installations <> 14 OR source_subinstallations <> 109 THEN
RAISE EXCEPTION 'Authoritative technical families must be 14 installations and 109 subinstallations, got % and %', source_installations,source_subinstallations;
RAISE EXCEPTION 'F6.1 must preserve technical family masters: installations %, subinstallations %', source_installations,source_subinstallations;
END IF;
SELECT COUNT(*) INTO source_findings
FROM finding_catalog_items item
JOIN finding_categories category ON category.id=item.category_id
WHERE lower(category.code)='authmodel' AND item.is_active=true;
IF source_findings <> 181 THEN
RAISE EXCEPTION 'Authoritative finding catalog must contain 181 items, got %', source_findings;
END IF;
SELECT COUNT(*) INTO concession_types FROM concession_types WHERE is_active=true;
IF concession_types <> 2 THEN
RAISE EXCEPTION 'Authoritative model must contain 2 concession types, got %', concession_types;
END IF;
SELECT COUNT(*) FILTER (WHERE lower(type.code)='departamento'),
COUNT(*) FILTER (WHERE lower(type.code)='area'),
COUNT(*) FILTER (WHERE lower(type.code)='yacimiento'),
COUNT(*) FILTER (WHERE type.operational_role='COMPANY')
INTO departments,areas,yacimientos,companies
FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id;
IF departments <> 7 OR areas <> 64 OR yacimientos <> 230 OR companies <> 13 THEN
RAISE EXCEPTION 'Authoritative territory mismatch: departments %, areas %, yacimientos %, companies %', departments,areas,yacimientos,companies;
END IF;
SELECT COUNT(*) INTO invalid_yacimientos
FROM assets yacimiento
JOIN asset_types type ON type.id=yacimiento.asset_type_id
JOIN assets area ON area.id=yacimiento.parent_id
WHERE lower(type.code)='yacimiento'
AND (
yacimiento.operational_area_id IS DISTINCT FROM area.id
OR yacimiento.operator_company_id IS NULL
OR yacimiento.concession_type_id IS NULL
);
IF invalid_yacimientos <> 0 THEN
RAISE EXCEPTION 'Authoritative model contains % invalid Yacimiento relationships', invalid_yacimientos;
WHERE lower(category.code)='f5model' AND item.is_active=true;
IF source_findings <> 177 THEN
RAISE EXCEPTION 'F6.1 must preserve finding master catalog, got %', source_findings;
END IF;
SELECT COUNT(*) INTO department_types
@@ -258,43 +210,16 @@ jobs:
IF area_department_rules <> 1 THEN
RAISE EXCEPTION 'Expected Area → Departamento canonical rule, got %', area_department_rules;
END IF;
SELECT COUNT(*) INTO yacimiento_area_rules
FROM asset_type_parent_rules rule
JOIN asset_types child ON child.id=rule.child_type_id
JOIN asset_types parent ON parent.id=rule.parent_type_id
WHERE lower(child.code)='yacimiento' AND lower(parent.code)='area';
IF yacimiento_area_rules <> 1 THEN
RAISE EXCEPTION 'Expected Yacimiento → Área canonical rule, got %', yacimiento_area_rules;
END IF;
SELECT COUNT(*) INTO installation_yacimiento_rules
FROM asset_type_parent_rules rule
JOIN asset_types child ON child.id=rule.child_type_id
JOIN asset_types parent ON parent.id=rule.parent_type_id
WHERE lower(child.code)='instalacion' AND lower(parent.code)='yacimiento';
IF installation_yacimiento_rules <> 1 THEN
RAISE EXCEPTION 'Expected Instalación → Yacimiento canonical rule, got %', installation_yacimiento_rules;
END IF;
SELECT COUNT(*) INTO subinstallation_installation_rules
FROM asset_type_parent_rules rule
JOIN asset_types child ON child.id=rule.child_type_id
JOIN asset_types parent ON parent.id=rule.parent_type_id
WHERE lower(child.code)='subinstalacion' AND lower(parent.code)='instalacion';
IF subinstallation_installation_rules <> 1 THEN
RAISE EXCEPTION 'Expected Subinstalación → Instalación canonical rule, got %', subinstallation_installation_rules;
END IF;
END $$;
SQL
# Destructive cuts are intentionally one-way: production rollback is
# the PRE database backup, not migration:revert. Prove the completed
# chain is idempotent and has no pending migration on a second run.
# Both destructive cuts are intentionally one-way: production rollback is
# the PRE database backup, not migration:revert. Prove the completed chain
# is idempotent and has no pending migration on a second run.
rerun_log="$(mktemp)"
docker compose --env-file .env.example --profile tools run --rm migrate 2>&1 | tee "$rerun_log"
grep -Eq 'No pending migrations|Applied migrations: 0' "$rerun_log" || {
echo "ERROR: authoritative migration chain is not idempotent." >&2
echo "ERROR: F6.1 migration chain is not idempotent." >&2
cat "$rerun_log" >&2
exit 1
}
-11
View File
@@ -1,11 +0,0 @@
# F6.4 · Android Campo Moderno
Criterios de aceptación:
- el buscador de Instalación padre consulta mientras se escribe y cada resultado se puede seleccionar directamente;
- la selección de Instalación abre la clasificación compatible de Subinstalación;
- Actas utiliza un read-model móvil desacoplado del módulo documental de oficina;
- la APK conserva GPS + foto antes de habilitar Hallazgos sobre Inventario nacido en campo;
- el lifecycle Acta DRAFT → LOCKED → SEALED no cambia;
- la interfaz de campo usa el tema DH moderno y mantiene acceso claro a Actas, Inventario y Hallazgos;
- Android lint/tests/debug/release, API tests/build, WEB build y Docker contract deben quedar verdes.
+14 -50
View File
@@ -1,71 +1,35 @@
# DH Inspección Android · release de campo 0.19.0
# DH Inspección Android · release F6.1
## Candidata vigente
- Fase funcional: **Campo final / Inspección → Acta → Inventario → Hallazgos → Firma**.
- `versionName`: **0.19.0**.
- `versionCode`: **28**.
- Fase funcional: **F6.1**.
- `versionName`: **0.15.1**.
- `versionCode`: **23**.
- Application ID release: `com.korexlabs.dhinspeccion`.
- Application ID debug/QA: `com.korexlabs.dhinspeccion.debug`.
- API: `https://dhv2.korexlabs.com/api/v3/`.
- Launcher de presentación: adaptación vectorial local inspirada en el escudo institucional publicado por Gobierno de Mendoza en `mendoza.gov.ar`, para reemplazar el ícono genérico del sistema.
La variante debug es independiente de la app productiva y puede instalarse para QA/presentación sin sobrescribir una instalación release histórica.
## Procedimiento de campo validado
1. La inspección se **planifica y asigna desde el tablero web**. La APK no crea inspecciones nuevas.
2. En la tablet el inspector abre una inspección asignada y, al llegar al lugar, pulsa **Iniciar inspección**.
3. Con la inspección en curso se crea o selecciona el **Acta** de trabajo. Sólo puede existir un Acta en borrador a la vez; una inspección puede contener varias Actas sucesivas.
4. Los **Hallazgos** se registran exclusivamente sobre **Instalaciones o Subinstalaciones**.
5. Si el elemento ya existe, se busca y selecciona desde Inventario de campo.
6. Si no existe, se da de alta desde la APK siguiendo la misma estructura que el tablero:
- Instalación dentro del Yacimiento de la inspección;
- Subinstalación dentro de una Instalación seleccionada;
- nombre técnico / identificación obligatorio;
- Código DH de campo autogenerado por el servidor;
- nombre habitual opcional;
- descripción y observaciones de alta opcionales;
- clasificación técnica obligatoria cuando el tipo la requiera;
- atributos técnicos dinámicos obligatorios según la configuración del tipo;
- Área y Operadora heredadas de la inspección planificada;
- ubicación del dispositivo y fotografía obligatorias antes de habilitar Hallazgos.
7. Sobre el elemento se selecciona un Hallazgo del catálogo contextual o **OTROS** cuando corresponda; se registra descripción, gravedad y evidencia fotográfica si aplica.
8. Al terminar el contenido se pulsa **Cerrar Acta y dejar pendiente de firma**. El contenido y los Hallazgos quedan inmutables y el estado visible pasa a **Pendiente de firma**.
9. Se registran las firmas y la manifestación de la empresa. La ausencia por sí sola no modifica el contenido cerrado: el Acta permanece pendiente hasta resolver el circuito de firma/manifestación conforme a la regla vigente.
10. Una vez resuelta la firma, el Acta queda **Firmada e inmutable**. La inspección sólo puede cerrarse cuando todas sus Actas no canceladas estén finalizadas.
## Castellano y presentación
La navegación operativa de la APK se presenta en castellano. Los valores técnicos que la API conserva internamente (`DRAFT`, `LOCKED`, `SEALED`, `PLANNED`, etc.) no se muestran al inspector; se traducen a **Borrador**, **Pendiente de firma**, **Firmada**, **Planificada**, **En curso**, etc.
También se incorporó la marca oficial de Mendoza provista para esta release en las pantallas de acceso y desbloqueo biométrico.
## Integridad del alta de Inventario
El alta móvil no duplica los formularios administrativos del tablero. Conserva los datos necesarios para que el registro tenga identidad y trazabilidad en campo, mientras que los datos administrativos que ya vienen determinados por la inspección (Área y Operadora) se heredan automáticamente.
El servidor continúa siendo la autoridad para:
- generar el Código DH `CAM-*` cuando el alta nace en campo;
- validar la jerarquía Yacimiento → Instalación → Subinstalación;
- exigir la familia/clasificación técnica compatible;
- validar atributos dinámicos configurados en el tablero;
- registrar ubicación de creación;
- impedir crear Hallazgos sobre un alta de campo que todavía no tenga ubicación y fotografía;
- conservar la trazabilidad si el alta provisional se fusiona posteriormente con un registro existente.
## Barrera obligatoria
Todo cambio Android o de API que pueda afectar al cliente móvil debe pasar `Android CI / RC`:
1. validación de identidad, HTTPS y políticas básicas del manifest;
2. Android lint;
3. unit tests Android reales, incluyendo contratos del procedimiento final;
3. unit tests Android reales, con verificación de que exista al menos una prueba ejecutada;
4. `assembleDebug`;
5. `assembleRelease` para comprobar que la variante productiva compile;
6. empaquetado del APK debug con SHA-256 y metadata de commit/versionado.
Además, `DH V2 CI` debe mantener verdes API, WEB y contrato Docker/migraciones antes de incorporar la release a `main`.
La barrera de lint exige además manejo explícito de la revocación de permisos de ubicación durante una captura GPS y declara la cámara como capacidad de hardware opcional, sin relajar permisos ni desactivar reglas globalmente.
El artefacto de CI contiene:
- APK debug;
- archivo `.sha256`;
- `release-metadata.txt` con fase, versión, versionCode, commit, applicationId, API base y canal.
## Firma release
@@ -80,5 +44,5 @@ Antes de distribuir una APK productiva:
- todas las barreras de CI del SHA exacto deben estar verdes;
- comprobar certificado/huella de firma contra la versión histórica;
- realizar actualización sobre al menos una tablet con la versión productiva anterior;
- ejecutar smoke funcional contra producción: ingreso, abrir inspección asignada, iniciar inspección, crear Acta, buscar Instalación/Subinstalación, alta de campo, ubicación/foto, Hallazgo, cerrar Acta a pendiente de firma, firma/finalización y cierre de inspección;
- ejecutar smoke funcional contra el entorno objetivo: login, lista de inspecciones, inicio, inventario, alta de campo, foto/GPS, Acta, Hallazgo y cierre;
- registrar el SHA Git y SHA-256 de la APK distribuida.
+2 -2
View File
@@ -12,8 +12,8 @@ android {
applicationId = "com.korexlabs.dhinspeccion"
minSdk = 26
targetSdk = 36
versionCode = 28
versionName = "0.19.0"
versionCode = 24
versionName = "0.15.2"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true
@@ -161,21 +161,25 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
val currentVisit = visit ?: return
val asset = selectedFieldAsset?.asset
if (currentVisit.status != "IN_PROGRESS") {
error = "La inspección debe estar en curso para crear un Acta."
error = "La Inspección debe estar en curso para crear un Acta."
return
}
if (urgency !in setOf("URGENT", "NON_URGENT")) {
error = "Elegí si el Acta es urgente o no urgente."
return
}
if (asset == null) {
error = "Seleccioná primero una Instalación o Subinstalación para iniciar el Acta."
return
}
if (acts.any { it.status == "DRAFT" }) {
error = "Ya existe un Acta en borrador. Cerrala antes de crear la siguiente."
error = "Ya existe un Acta en borrador. Bloqueala o cancelala antes de crear la siguiente."
return
}
launchBusy {
val created = actsRepository.create(
currentVisit.id,
asset?.id,
asset.id,
currentVisit.code,
urgency,
)
@@ -184,18 +188,17 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
loadActsInternal(currentVisit.id, selectDraft = false)
val urgencyLabel = if (urgency == "URGENT") "urgente" else "no urgente"
notice = "${created.code} creada como $urgencyLabel. Los Hallazgos nuevos quedarán vinculados explícitamente a esta Acta."
if (asset != null && selectedFieldAsset?.capture?.readyForFinding == true) {
if (selectedFieldAsset?.capture?.readyForFinding == true) {
loadFindingOptionsInternal(currentVisit.id, asset.id, created.id)
}
}
}
fun createAct(urgency: String = "NON_URGENT") = createActForSelectedInventory(urgency)
fun searchInventory(search: String, parentId: String? = null) {
val currentVisit = visit ?: return
launchBusy {
inventory = repository.fieldInventory(currentVisit.id, search, parentId).data
val effectiveParentId = parentId ?: inventoryParentId ?: currentVisit.scopeAsset?.id ?: currentVisit.operationalArea?.id
inventory = repository.fieldInventory(currentVisit.id, search, effectiveParentId).data
}
}
@@ -218,7 +221,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
val visitId = visit?.id ?: return
launchBusy {
selectedFieldAsset = repository.selectFieldAsset(visitId, item.id)
notice = "Inventario agregado a la inspección."
notice = "Inventario agregado a la Inspección."
reloadCurrentInventory(visitId)
val draft = selectedDraftAct()
if (selectedFieldAsset?.capture?.readyForFinding == true && draft != null) {
@@ -237,8 +240,6 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
familyId: String?,
name: String,
commonName: String?,
description: String?,
discoveryNotes: String?,
attributes: Map<String, Any?>,
latitude: Double,
longitude: Double,
@@ -246,7 +247,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
) {
val visitId = visit?.id ?: return
if (visit?.status != "IN_PROGRESS") {
error = "La inspección debe estar en curso para dar de alta Inventario."
error = "La Inspección debe estar en curso para dar de alta Inventario."
return
}
if (type.familyRequired && familyId == null) {
@@ -260,8 +261,6 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
familyId = familyId,
name = name.trim(),
commonName = commonName?.trim()?.takeIf { it.isNotBlank() },
description = description?.trim()?.takeIf { it.isNotBlank() },
discoveryNotes = discoveryNotes?.trim()?.takeIf { it.isNotBlank() },
attributes = attributes,
deviceLatitude = latitude,
deviceLongitude = longitude,
@@ -269,7 +268,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
deviceCapturedAt = Instant.now().toString(),
)
selectedFieldAsset = repository.createFieldAsset(visitId, request)
notice = "Inventario creado con ubicación. Falta la fotografía obligatoria. Si ya existía, podés fusionarlo antes de continuar."
notice = "Inventario creado con GPS. Falta la fotografía obligatoria. Si ya existía, podés fusionarlo antes de continuar."
reloadCurrentInventory(visitId)
}
}
@@ -323,7 +322,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
)
selectedFieldAsset = selectedFieldAsset?.copy(capture = response.capture)
notice = if (response.capture.readyForFinding) {
"Captura completa: ubicación y fotografía registradas."
"Captura completa: GPS y fotografía registrados."
} else {
"Fotografía registrada."
}
@@ -423,7 +422,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
description = description,
)
loadEvidenceInternal(findingId)
notice = "Evidencia fotográfica registrada con ubicación."
notice = "Evidencia fotográfica registrada con GPS."
}
}
@@ -475,7 +474,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
absenceReason = reason.trim(),
),
)
notice = "Ausencia del responsable registrada. Al cerrar el Acta, dejá asentada la ausencia en el circuito de firma."
notice = "Ausencia del responsable registrada. La manifestación de empresa quedará pendiente y deberá resolverse antes de sellar el Acta."
}
}
@@ -484,12 +483,12 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
launchBusy {
actClosure = actsRepository.lock(actId)
refreshSelectedActInternal(actId)
notice = "Acta cerrada y pendiente de firma. El contenido y los Hallazgos quedaron inmutables."
notice = "Acta bloqueada. Su contenido quedó inmutable; ahora deben resolverse las firmas y manifestaciones."
}
}
fun reopenSelectedAct() {
error = "Un Acta cerrada y pendiente de firma es inmutable y no puede volver a borrador."
error = "Un Acta bloqueada es inmutable y no puede volver a borrador."
}
fun signSelectedActAsInspector(
@@ -528,8 +527,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun recordCompanyOutcome(status: String, reason: String) {
val actId = selectedAct?.id ?: return
if (status !in setOf("REFUSED", "ABSENT")) {
error = "La manifestación de la empresa debe resolverse con firma, negativa o ausencia documentada."
if (status != "REFUSED") {
error = "La ausencia no resuelve la manifestación de la empresa. Registrá una firma o una negativa."
return
}
if (reason.trim().length < 10) {
@@ -538,11 +537,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
}
launchBusy {
actClosure = actsRepository.companyOutcome(actId, status, reason)
notice = if (status == "ABSENT") {
"Ausencia para la firma de empresa asentada."
} else {
"Negativa a firmar asentada."
}
notice = "Negativa a firmar asentada."
}
}
@@ -554,7 +549,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
refreshSelectedActInternal(actId)
loadActsInternal(currentVisit.id, selectDraft = false)
clearFindingState()
notice = "${selectedAct?.code ?: "Acta"} firmada, finalizada e inmutable. Podés crear otra Acta o continuar hacia el cierre de la inspección."
notice = "${selectedAct?.code ?: "Acta"} SELLADA e inmutable. Podés crear otra Acta o continuar hacia el cierre de la Inspección."
}
}
@@ -563,7 +558,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
launchBusy {
visit = actsRepository.closeVisit(visitId)
loadVisitsInternal()
notice = "Inspección cerrada. Todas sus Actas quedaron firmadas y disponibles para el circuito de oficina."
notice = "Inspección cerrada. Todas sus Actas quedaron SELLADAS y disponibles para el circuito de oficina."
}
}
@@ -207,7 +207,7 @@ data class MobileCloseVisitRequest(
)
private interface MobileActsApi {
@GET("inspection-visits/{visitId}/acts/mobile")
@GET("inspection-visits/{visitId}/acts")
suspend fun listActs(
@Header("Authorization") authorization: String,
@Path("visitId") visitId: String,
@@ -329,7 +329,7 @@ class MobileActsRepository(context: Context) {
suspend fun create(
visitId: String,
assetId: String? = null,
assetId: String,
visitCode: String,
urgency: String = "NON_URGENT",
): MobileActDetail = authorized { session ->
@@ -341,7 +341,7 @@ class MobileActsRepository(context: Context) {
urgency = urgency,
title = "Acta de inspección $visitCode",
summary = "Acta de inspección en curso. Los Hallazgos y observaciones se incorporan de forma trazable durante la inspección.",
assetIds = listOfNotNull(assetId),
assetIds = listOf(assetId),
),
)
}
@@ -1,73 +0,0 @@
package com.korexlabs.dhinspeccion.ui
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
private val DhLightColors = lightColorScheme(
primary = Color(0xFF075E63),
onPrimary = Color(0xFFFFFFFF),
primaryContainer = Color(0xFFD6F0F0),
onPrimaryContainer = Color(0xFF073F43),
secondary = Color(0xFF445E74),
onSecondary = Color(0xFFFFFFFF),
secondaryContainer = Color(0xFFDCE7F1),
onSecondaryContainer = Color(0xFF243F54),
tertiary = Color(0xFF6A5A42),
background = Color(0xFFF6F8FA),
onBackground = Color(0xFF182025),
surface = Color(0xFFFFFFFF),
onSurface = Color(0xFF182025),
surfaceVariant = Color(0xFFEDF1F4),
onSurfaceVariant = Color(0xFF455158),
outline = Color(0xFF77848B),
outlineVariant = Color(0xFFD7DEE2),
error = Color(0xFFB3261E),
onError = Color(0xFFFFFFFF),
errorContainer = Color(0xFFF9DEDC),
onErrorContainer = Color(0xFF6F1713),
)
private val DhDarkColors = darkColorScheme(
primary = Color(0xFF88D4D7),
onPrimary = Color(0xFF00373A),
primaryContainer = Color(0xFF074F53),
onPrimaryContainer = Color(0xFFB7EBED),
secondary = Color(0xFFB6CBE0),
onSecondary = Color(0xFF203646),
secondaryContainer = Color(0xFF344C60),
onSecondaryContainer = Color(0xFFDCE7F1),
background = Color(0xFF101416),
onBackground = Color(0xFFE0E4E6),
surface = Color(0xFF171C1F),
onSurface = Color(0xFFE0E4E6),
surfaceVariant = Color(0xFF263035),
onSurfaceVariant = Color(0xFFC3CDD2),
outline = Color(0xFF8C989E),
error = Color(0xFFFFB4AB),
onError = Color(0xFF690005),
errorContainer = Color(0xFF93000A),
onErrorContainer = Color(0xFFFFDAD6),
)
private val DhShapes = androidx.compose.material3.Shapes(
extraSmall = RoundedCornerShape(8.dp),
small = RoundedCornerShape(12.dp),
medium = RoundedCornerShape(16.dp),
large = RoundedCornerShape(22.dp),
extraLarge = RoundedCornerShape(28.dp),
)
@Composable
fun DhTheme(content: @Composable () -> Unit) {
MaterialTheme(
colorScheme = if (isSystemInDarkTheme()) DhDarkColors else DhLightColors,
shapes = DhShapes,
content = content,
)
}
@@ -1,972 +0,0 @@
package com.korexlabs.dhinspeccion.ui
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Environment
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.exifinterface.media.ExifInterface
import com.google.android.gms.location.LocationServices
import com.google.android.gms.location.Priority
import com.google.android.gms.tasks.CancellationTokenSource
import com.korexlabs.dhinspeccion.MainViewModel
import com.korexlabs.dhinspeccion.data.FieldAttributeDefinition
import com.korexlabs.dhinspeccion.data.FieldInventoryItem
import com.korexlabs.dhinspeccion.data.FieldType
import com.korexlabs.dhinspeccion.data.VisitDetail
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import java.io.File
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
private data class DynamicGeoSnapshot(
val latitude: Double,
val longitude: Double,
val accuracyM: Double?,
)
private enum class DynamicInventoryMode {
BROWSE,
PICK_PARENT,
CREATE_INSTALLATION,
CREATE_SUBINSTALLATION,
}
@Composable
fun DynamicVisitRoot(model: MainViewModel) {
var inventoryMode by rememberSaveable(model.visit?.id) { mutableStateOf(false) }
var actsMode by rememberSaveable(model.visit?.id) { mutableStateOf(false) }
when {
actsMode -> MobileActsScreen(
model = model,
onBack = { actsMode = false },
onGoInventory = {
actsMode = false
inventoryMode = true
},
)
inventoryMode -> DynamicFieldInventoryScreen(
model = model,
onBack = {
inventoryMode = false
actsMode = true
},
)
else -> DynamicVisitOverview(
model = model,
onInventory = { inventoryMode = true },
onActs = { actsMode = true },
)
}
}
@Composable
private fun DynamicVisitOverview(
model: MainViewModel,
onInventory: () -> Unit,
onActs: () -> Unit,
) {
val visit = model.visit ?: return
Column(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(top = 28.dp, start = 16.dp, end = 16.dp, bottom = 30.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedButton(onClick = { model.closeVisitView() }) { Text("Volver") }
Text(dynamicStatusLabel(visit.status), fontWeight = FontWeight.Bold)
}
Text(visit.code, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
Text("${visit.operatorCompany?.name ?: "Sin operadora"} · ${visit.operationalArea?.name ?: "Sin área"}")
visit.scopeAsset?.let {
Text("Yacimiento: ${it.name}", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold)
}
visit.plannedStartAt?.let {
Text("Planificada: ${dynamicShortDate(it)}", style = MaterialTheme.typography.bodySmall)
}
visit.instructions?.takeIf { it.isNotBlank() }?.let {
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) {
Text("Instrucciones", fontWeight = FontWeight.Bold)
Text(it)
}
}
}
DynamicMessageStrip(model)
if (visit.status == "PLANNED") {
Card(
Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Todo listo para comenzar", fontWeight = FontWeight.Bold)
Text("Al iniciar se habilitan Actas, Hallazgos y altas rápidas de campo.")
Button(
onClick = { model.startVisit(); onActs() },
enabled = !model.busy,
modifier = Modifier.fillMaxWidth(),
) { Text(if (model.busy) "Iniciando…" else "Iniciar inspección") }
}
}
}
if (visit.status == "IN_PROGRESS") {
Card(
Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer),
) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Modo campo rápido", fontWeight = FontWeight.Bold)
Text(
"Desde Inventario podés cargar una Subinstalación en tres pasos: elegir Instalación, identificarla y capturar GPS + foto.",
style = MaterialTheme.typography.bodySmall,
)
Button(onClick = onInventory, modifier = Modifier.fillMaxWidth()) {
Text("+ Nueva subinstalación / inventario")
}
}
}
Button(onClick = onActs, modifier = Modifier.fillMaxWidth()) {
val open = model.acts.count { it.status == "DRAFT" || it.status == "READY" }
Text("Abrir Actas · ${model.acts.size}${if (open > 0) " · $open abiertas" else ""}")
}
OutlinedButton(onClick = onInventory, modifier = Modifier.fillMaxWidth()) {
Text("Buscar Inventario / Hallazgos")
}
} else if (visit.status == "CLOSED") {
OutlinedButton(onClick = onActs, modifier = Modifier.fillMaxWidth()) {
Text("Ver Actas · ${model.acts.size}")
}
}
DynamicChecklistCard(visit)
Text("Inventario planificado", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
if (visit.planningAssets.none { it.included }) {
Text("Sin elementos planificados.")
}
visit.planningAssets.filter { it.included }.forEach { asset ->
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(10.dp)) {
Text(asset.name, fontWeight = FontWeight.SemiBold)
Text("${asset.code} · ${asset.typeName.orEmpty()}", style = MaterialTheme.typography.bodySmall)
}
}
}
}
}
@Composable
private fun DynamicChecklistCard(visit: VisitDetail) {
val checklist = visit.checklist
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text("Antecedentes antes de salir", fontWeight = FontWeight.Bold)
Text("Vencidos empresa: ${checklist.companyOverdue} · Verificaciones vencidas: ${checklist.verificationOverdue}")
Text("Antecedentes: ${checklist.antecedents} · Próximos controles: ${checklist.upcomingControls}")
if (checklist.stale) {
Text("El checklist requiere revisión/actualización.", color = MaterialTheme.colorScheme.error)
}
}
}
}
@Composable
private fun DynamicFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val visit = model.visit ?: return
var modeName by rememberSaveable(visit.id) { mutableStateOf(DynamicInventoryMode.BROWSE.name) }
val mode = runCatching { DynamicInventoryMode.valueOf(modeName) }.getOrDefault(DynamicInventoryMode.BROWSE)
var search by rememberSaveable(visit.id) { mutableStateOf("") }
var parentSearch by rememberSaveable(visit.id) { mutableStateOf("") }
var parentId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
var parentLabel by rememberSaveable(visit.id) { mutableStateOf("") }
var name by rememberSaveable(visit.id) { mutableStateOf("") }
var commonName by rememberSaveable(visit.id) { mutableStateOf("") }
var selectedTypeId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
var selectedFamilyId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
var familySearch by rememberSaveable(visit.id) { mutableStateOf("") }
val attributeValues = remember { mutableStateMapOf<String, String>() }
var pendingAutoPhoto by rememberSaveable(visit.id) { mutableStateOf(false) }
var localError by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
var permissionAction by rememberSaveable(visit.id) { mutableStateOf("PHOTO") }
var mergeSearch by rememberSaveable(visit.id) { mutableStateOf("") }
var mergeCandidateId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
var mergeReason by rememberSaveable(visit.id) { mutableStateOf("") }
fun resetForm() {
name = ""
commonName = ""
selectedTypeId = null
selectedFamilyId = null
familySearch = ""
attributeValues.clear()
localError = null
}
fun backToBrowse() {
modeName = DynamicInventoryMode.BROWSE.name
parentId = null
parentLabel = ""
parentSearch = ""
resetForm()
model.loadFieldTypes(null)
}
fun startInstallation() {
model.clearSelectedFieldAsset()
modeName = DynamicInventoryMode.CREATE_INSTALLATION.name
parentId = null
parentLabel = visit.scopeAsset?.name ?: "Yacimiento de la inspección"
resetForm()
model.loadFieldTypes(null)
}
fun startSubinstallation() {
model.clearSelectedFieldAsset()
modeName = DynamicInventoryMode.PICK_PARENT.name
parentId = null
parentLabel = ""
parentSearch = ""
resetForm()
model.searchInventory("", visit.scopeAsset?.id)
}
fun chooseParent(item: FieldInventoryItem) {
parentId = item.id
parentLabel = "${item.name} (${item.code})"
modeName = DynamicInventoryMode.CREATE_SUBINSTALLATION.name
resetForm()
model.loadFieldTypes(item.id)
}
LaunchedEffect(visit.id) {
model.searchInventory("", visit.scopeAsset?.id)
}
LaunchedEffect(model.fieldTypes, modeName) {
if (mode == DynamicInventoryMode.CREATE_INSTALLATION || mode == DynamicInventoryMode.CREATE_SUBINSTALLATION) {
val preferred = model.fieldTypes.firstOrNull { type ->
val normalized = dynamicNormalize(type.structuralKind ?: type.code.ifBlank { type.name })
when (mode) {
DynamicInventoryMode.CREATE_INSTALLATION -> normalized.contains("instalacion") && !normalized.contains("subinstalacion")
DynamicInventoryMode.CREATE_SUBINSTALLATION -> normalized.contains("subinstalacion")
else -> false
}
} ?: model.fieldTypes.firstOrNull()
if (model.fieldTypes.none { it.id == selectedTypeId }) {
selectedTypeId = preferred?.id
selectedFamilyId = null
familySearch = ""
attributeValues.clear()
}
}
}
val selectedType = model.fieldTypes.firstOrNull { it.id == selectedTypeId }
val selectedFamily = selectedType?.families?.firstOrNull { it.id == selectedFamilyId }
val filteredFamilies = remember(selectedType, familySearch) {
val needle = dynamicNormalize(familySearch)
selectedType?.families.orEmpty().filter { family ->
needle.isBlank() || dynamicNormalize("${family.code} ${family.name}").contains(needle)
}
}
var pendingPhotoFile by remember { mutableStateOf<File?>(null) }
var pendingPhotoGeo by remember { mutableStateOf<DynamicGeoSnapshot?>(null) }
val takePicture = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { success ->
val file = pendingPhotoFile
val geo = pendingPhotoGeo
if (success && file != null && geo != null) {
runCatching { writeDynamicExif(file, geo) }
model.uploadFieldPhoto(file, geo.latitude, geo.longitude, geo.accuracyM)
} else if (!success) {
localError = "La foto quedó pendiente. Podés tomarla cuando estés listo."
}
pendingPhotoFile = null
pendingPhotoGeo = null
}
val beginPhoto: () -> Unit = {
scope.launch {
runCatching { currentDynamicGeo(context) }
.onSuccess { geo ->
val (file, uri) = newDynamicPhoto(context)
pendingPhotoFile = file
pendingPhotoGeo = geo
takePicture.launch(uri)
}
.onFailure { localError = it.message ?: "No se pudo obtener el GPS para la fotografía." }
}
}
fun createWithLocation() {
val type = selectedType ?: return
val effectiveParent = when (mode) {
DynamicInventoryMode.CREATE_SUBINSTALLATION -> parentId
else -> null
}
scope.launch {
runCatching { currentDynamicGeo(context) }
.onSuccess { geo ->
pendingAutoPhoto = true
model.createFieldAsset(
type = type,
parentId = effectiveParent,
familyId = selectedFamilyId,
name = name,
commonName = commonName,
attributes = buildDynamicAttributes(type, attributeValues),
latitude = geo.latitude,
longitude = geo.longitude,
accuracyM = geo.accuracyM,
)
}
.onFailure { localError = it.message ?: "No se pudo capturar la ubicación GPS." }
}
}
val permissionsLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result ->
val cameraAllowed = result[Manifest.permission.CAMERA] == true || dynamicHasPermission(context, Manifest.permission.CAMERA)
val locationAllowed = result[Manifest.permission.ACCESS_FINE_LOCATION] == true ||
result[Manifest.permission.ACCESS_COARSE_LOCATION] == true || dynamicHasLocation(context)
when (permissionAction) {
"CREATE" -> {
if (cameraAllowed && locationAllowed) createWithLocation()
else localError = "Para el alta rápida se necesitan cámara y ubicación."
}
else -> {
if (cameraAllowed && locationAllowed) beginPhoto()
else localError = "Para completar el alta se necesitan cámara y ubicación."
}
}
}
fun requestCreate() {
permissionAction = "CREATE"
if (dynamicHasPermission(context, Manifest.permission.CAMERA) && dynamicHasLocation(context)) {
createWithLocation()
} else {
permissionsLauncher.launch(
arrayOf(
Manifest.permission.CAMERA,
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION,
),
)
}
}
fun requestPhoto() {
permissionAction = "PHOTO"
if (dynamicHasPermission(context, Manifest.permission.CAMERA) && dynamicHasLocation(context)) {
beginPhoto()
} else {
permissionsLauncher.launch(
arrayOf(
Manifest.permission.CAMERA,
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION,
),
)
}
}
val selectedCapture = model.selectedFieldAsset
LaunchedEffect(selectedCapture?.asset?.id, pendingAutoPhoto) {
val capture = selectedCapture?.capture
if (pendingAutoPhoto && capture?.captureRequired == true && capture.creationGpsCaptured && capture.fieldPhotoCount == 0) {
pendingAutoPhoto = false
requestPhoto()
}
}
val mergeSource = selectedCapture?.takeIf {
it.capture.captureRequired && dynamicItemTypeCode(it.asset) in setOf("instalacion", "subinstalacion")
}
val mergeCandidates = remember(model.inventory, mergeSource) {
if (mergeSource == null) emptyList() else model.inventory.filter { candidate ->
candidate.id != mergeSource.asset.id &&
candidate.informationStatus != "INACTIVE" &&
candidate.type?.id == mergeSource.asset.type?.id &&
candidate.parent?.id == mergeSource.asset.parent?.id &&
!candidate.captureRequired
}
}
Column(Modifier.fillMaxSize().padding(top = 28.dp)) {
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedButton(onClick = { if (mode == DynamicInventoryMode.BROWSE) onBack() else backToBrowse() }) {
Text(if (mode == DynamicInventoryMode.BROWSE) "Acta" else "Atrás")
}
Column(horizontalAlignment = Alignment.End) {
Text(
when (mode) {
DynamicInventoryMode.PICK_PARENT -> "Elegir Instalación"
DynamicInventoryMode.CREATE_INSTALLATION -> "Nueva Instalación"
DynamicInventoryMode.CREATE_SUBINSTALLATION -> "Nueva Subinstalación"
else -> "Inventario de campo"
},
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
)
Text(visit.scopeAsset?.name ?: visit.code, style = MaterialTheme.typography.bodySmall)
}
}
Column(Modifier.padding(horizontal = 16.dp)) {
DynamicMessageStrip(model)
localError?.let {
Card(
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer),
onClick = { localError = null },
) { Text(it, Modifier.padding(12.dp)) }
}
}
if (selectedCapture != null) {
DynamicCaptureCard(
item = selectedCapture.asset,
gps = selectedCapture.capture.creationGpsCaptured,
photos = selectedCapture.capture.fieldPhotoCount,
ready = selectedCapture.capture.readyForFinding,
onPhoto = { requestPhoto() },
onClose = { model.clearSelectedFieldAsset() },
onCreateAnother = {
if (dynamicItemTypeCode(selectedCapture.asset) == "subinstalacion" && selectedCapture.asset.parent != null) {
chooseParent(
FieldInventoryItem(
id = selectedCapture.asset.parent.id,
code = selectedCapture.asset.parent.code,
name = selectedCapture.asset.parent.name,
type = null,
),
)
} else {
startSubinstallation()
}
},
)
}
if (mergeSource != null && !mergeSource.capture.readyForFinding) {
Card(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer),
) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("¿Ya existía?", fontWeight = FontWeight.Bold)
Text("Antes de seguir, podés fusionar el alta con un registro existente compatible.", style = MaterialTheme.typography.bodySmall)
OutlinedTextField(
value = mergeSearch,
onValueChange = { mergeSearch = it },
label = { Text("Buscar posible duplicado") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
OutlinedButton(
onClick = {
mergeCandidateId = null
model.searchInventory(mergeSearch, mergeSource.asset.parent?.id)
},
modifier = Modifier.fillMaxWidth(),
) { Text("Buscar coincidencias") }
mergeCandidates.take(6).forEach { candidate ->
AssistChip(
onClick = { mergeCandidateId = candidate.id },
label = {
Text(if (mergeCandidateId == candidate.id) "${candidate.code} · ${candidate.name}" else "${candidate.code} · ${candidate.name}")
},
)
}
if (mergeCandidateId != null) {
OutlinedTextField(
value = mergeReason,
onValueChange = { mergeReason = it },
label = { Text("Motivo de la fusión") },
modifier = Modifier.fillMaxWidth(),
minLines = 2,
)
Button(
onClick = { model.mergeCreatedFieldAsset(mergeCandidateId!!, mergeReason) },
enabled = mergeReason.trim().length >= 8 && !model.busy,
modifier = Modifier.fillMaxWidth(),
) { Text("Fusionar con el existente") }
}
}
}
}
when (mode) {
DynamicInventoryMode.BROWSE -> {
Column(
Modifier.fillMaxWidth().padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Card(
Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Alta rápida en campo", fontWeight = FontWeight.Bold)
Text("La Subinstalación se carga guiada, sin navegar por formularios largos.", style = MaterialTheme.typography.bodySmall)
Button(onClick = { startSubinstallation() }, modifier = Modifier.fillMaxWidth(), enabled = visit.status == "IN_PROGRESS") {
Text("+ Nueva Subinstalación")
}
OutlinedButton(onClick = { startInstallation() }, modifier = Modifier.fillMaxWidth(), enabled = visit.status == "IN_PROGRESS") {
Text("+ Nueva Instalación")
}
}
}
OutlinedTextField(
value = search,
onValueChange = { search = it },
label = { Text("Buscar instalación, subinstalación o código") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Button(
onClick = { model.searchInventory(search) },
enabled = !model.busy,
modifier = Modifier.fillMaxWidth(),
) { Text("Buscar") }
}
Text("Inventario disponible", Modifier.padding(horizontal = 16.dp, vertical = 8.dp), fontWeight = FontWeight.Bold)
LazyColumn(
Modifier.fillMaxSize().padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(
model.inventory.filter { dynamicItemTypeCode(it) in setOf("instalacion", "subinstalacion") },
key = { it.id },
) { item ->
DynamicInventoryCard(
item = item,
onInspect = { model.selectExisting(item) },
onAddChild = { chooseParent(item) },
)
}
item { Spacer(Modifier.height(30.dp)) }
}
}
DynamicInventoryMode.PICK_PARENT -> {
Column(
Modifier.fillMaxWidth().padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
DynamicStepHeader(1, "Elegí la Instalación padre")
Text("Solo se muestran Instalaciones del Yacimiento de esta inspección.", style = MaterialTheme.typography.bodySmall)
OutlinedTextField(
value = parentSearch,
onValueChange = { parentSearch = it },
label = { Text("Buscar Instalación por nombre o código") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Button(
onClick = { model.searchInventory(parentSearch, visit.scopeAsset?.id) },
enabled = !model.busy,
modifier = Modifier.fillMaxWidth(),
) { Text("Buscar Instalación") }
}
LazyColumn(
Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(
model.inventory.filter { dynamicItemTypeCode(it) == "instalacion" },
key = { it.id },
) { item ->
Card(onClick = { chooseParent(item) }, modifier = Modifier.fillMaxWidth()) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(item.name, fontWeight = FontWeight.Bold)
Text(item.code, style = MaterialTheme.typography.bodySmall)
Text("Tocar para agregar una Subinstalación", color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.bodySmall)
}
}
}
item { Spacer(Modifier.height(24.dp)) }
}
}
DynamicInventoryMode.CREATE_INSTALLATION,
DynamicInventoryMode.CREATE_SUBINSTALLATION -> {
val isSubinstallation = mode == DynamicInventoryMode.CREATE_SUBINSTALLATION
Column(
Modifier
.fillMaxSize()
.padding(horizontal = 16.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
DynamicStepHeader(if (isSubinstallation) 2 else 1, if (isSubinstallation) "Identificá la Subinstalación" else "Identificá la Instalación")
Text("Ubicación: $parentLabel", style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.SemiBold)
if (isSubinstallation) {
OutlinedButton(onClick = { startSubinstallation() }, modifier = Modifier.fillMaxWidth()) {
Text("Cambiar Instalación padre")
}
}
if (model.fieldTypes.isEmpty()) {
Text("No hay un tipo habilitado para esta ubicación.", color = MaterialTheme.colorScheme.error)
}
if (model.fieldTypes.size > 1) {
Text("Nivel de Inventario", fontWeight = FontWeight.Bold)
model.fieldTypes.forEach { type ->
AssistChip(
onClick = {
selectedTypeId = type.id
selectedFamilyId = null
attributeValues.clear()
},
label = { Text(if (selectedTypeId == type.id) "${type.name}" else type.name) },
)
}
}
if (selectedType?.familyRequired == true) {
Text("Clasificación técnica *", fontWeight = FontWeight.Bold)
OutlinedTextField(
value = familySearch,
onValueChange = { familySearch = it },
label = { Text("Buscar clasificación") },
supportingText = { Text("${filteredFamilies.size} opciones compatibles") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
filteredFamilies.take(12).forEach { family ->
AssistChip(
onClick = { selectedFamilyId = family.id },
label = {
val prefix = when {
selectedFamilyId == family.id -> ""
family.isOther -> "+ "
else -> ""
}
Text(prefix + family.name)
},
)
}
if (filteredFamilies.size > 12) {
Text("Escribí parte del nombre para reducir la lista.", style = MaterialTheme.typography.bodySmall)
}
selectedFamily?.let { family ->
if (family.isOther) {
Text("Quedará marcado como no catalogado para revisión en oficina.", color = MaterialTheme.colorScheme.secondary)
}
if (family.informationLabels.isNotEmpty()) {
Text("Datos esperados: ${family.informationLabels.joinToString(" · ")}", style = MaterialTheme.typography.bodySmall)
}
}
}
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Nombre o código visible *") },
supportingText = { Text("Usá lo que ve el inspector en la placa o en campo.") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
OutlinedTextField(
value = commonName,
onValueChange = { commonName = it },
label = { Text("Nombre habitual") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
selectedType?.attributes?.forEach { definition ->
OutlinedTextField(
value = attributeValues[definition.code].orEmpty(),
onValueChange = { attributeValues[definition.code] = it },
label = { Text(definition.name + if (definition.isRequired) " *" else "") },
supportingText = {
val details = listOfNotNull(definition.unit, definition.options?.toString()).joinToString(" · ")
if (details.isNotBlank()) Text(details)
},
keyboardOptions = KeyboardOptions(
keyboardType = if (definition.dataType.uppercase() in setOf("NUMBER", "DECIMAL", "INTEGER", "FLOAT")) KeyboardType.Decimal else KeyboardType.Text,
),
modifier = Modifier.fillMaxWidth(),
)
}
HorizontalDivider()
DynamicStepHeader(if (isSubinstallation) 3 else 2, "GPS + foto en un solo paso")
Text(
"Al guardar se captura la ubicación actual y la cámara se abre automáticamente. La foto sigue siendo obligatoria antes de crear Hallazgos.",
style = MaterialTheme.typography.bodySmall,
)
val attributesReady = selectedType?.attributes
?.filter { it.isRequired }
?.all { attributeValues[it.code].orEmpty().isNotBlank() }
?: false
val familyReady = selectedType?.familyRequired != true || selectedFamilyId != null
Button(
onClick = { requestCreate() },
enabled = selectedType != null && name.isNotBlank() && attributesReady && familyReady && !model.busy,
modifier = Modifier.fillMaxWidth(),
) { Text(if (model.busy) "Guardando…" else "Guardar y tomar foto") }
Spacer(Modifier.height(30.dp))
}
}
}
}
}
@Composable
private fun DynamicStepHeader(step: Int, title: String) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer)) {
Text(step.toString(), Modifier.padding(horizontal = 10.dp, vertical = 6.dp), fontWeight = FontWeight.Bold)
}
Spacer(Modifier.width(8.dp))
Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
}
}
@Composable
private fun DynamicCaptureCard(
item: FieldInventoryItem,
gps: Boolean,
photos: Int,
ready: Boolean,
onPhoto: () -> Unit,
onClose: () -> Unit,
onCreateAnother: () -> Unit,
) {
Card(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp),
colors = CardDefaults.cardColors(
containerColor = if (ready) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant,
),
) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(7.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Column(Modifier.weight(1f)) {
Text(item.name, fontWeight = FontWeight.Bold)
Text(item.code, style = MaterialTheme.typography.bodySmall)
}
OutlinedButton(onClick = onClose) { Text("Cerrar") }
}
Text("GPS: ${if (gps) "OK" else "pendiente"} · Fotos: $photos")
if (!ready) {
Text("Falta la fotografía obligatoria.", color = MaterialTheme.colorScheme.error)
Button(onClick = onPhoto, modifier = Modifier.fillMaxWidth()) { Text("Tomar foto ahora") }
} else {
Text("Alta completa · lista para Hallazgos", color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold)
OutlinedButton(onClick = onCreateAnother, modifier = Modifier.fillMaxWidth()) {
Text("+ Crear otra Subinstalación")
}
}
}
}
}
@Composable
private fun DynamicInventoryCard(
item: FieldInventoryItem,
onInspect: () -> Unit,
onAddChild: () -> Unit,
) {
val typeCode = dynamicItemTypeCode(item)
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(item.name, fontWeight = FontWeight.SemiBold)
Text(if (typeCode == "subinstalacion") "Subinstalación" else "Instalación", style = MaterialTheme.typography.bodySmall)
}
Text(item.code, style = MaterialTheme.typography.bodySmall)
item.commonName?.takeIf { it.isNotBlank() }?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
Text(
if (item.readyForFinding) "Disponible" else "GPS/foto pendiente",
color = if (item.readyForFinding) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
Button(onClick = onInspect, modifier = Modifier.fillMaxWidth()) {
Text(if (item.selectedInInspection) "Abrir para Hallazgo" else "Usar para Hallazgo")
}
if (typeCode == "instalacion") {
OutlinedButton(onClick = onAddChild, modifier = Modifier.fillMaxWidth()) {
Text("+ Agregar Subinstalación")
}
}
}
}
}
@Composable
private fun DynamicMessageStrip(model: MainViewModel) {
val error = model.error
val notice = model.notice
if (error != null || notice != null) {
Card(
modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp),
colors = CardDefaults.cardColors(
containerColor = if (error != null) MaterialTheme.colorScheme.errorContainer else MaterialTheme.colorScheme.secondaryContainer,
),
onClick = { model.clearMessages() },
) {
Text(error ?: notice.orEmpty(), Modifier.padding(12.dp))
}
}
}
private fun dynamicItemTypeCode(item: FieldInventoryItem): String {
val type = item.type ?: return ""
return dynamicNormalize(type.typeName ?: type.name)
}
private fun dynamicNormalize(value: String): String = value.trim().lowercase()
.replace('ó', 'o')
.replace('í', 'i')
.replace('á', 'a')
.replace('é', 'e')
.replace('ú', 'u')
.replace("_", "")
.replace("-", "")
.replace(" ", "")
private fun dynamicStatusLabel(status: String): String = when (status) {
"PLANNED" -> "Planificada"
"IN_PROGRESS" -> "En curso"
"CLOSED" -> "Cerrada"
"CANCELLED" -> "Cancelada"
else -> status
}
private fun buildDynamicAttributes(type: FieldType, values: Map<String, String>): Map<String, Any?> =
type.attributes.mapNotNull { definition ->
val raw = values[definition.code]?.trim().orEmpty()
if (raw.isBlank()) return@mapNotNull null
definition.code to coerceDynamicAttribute(definition, raw)
}.toMap()
private fun coerceDynamicAttribute(definition: FieldAttributeDefinition, raw: String): Any = when (definition.dataType.uppercase()) {
"INTEGER", "INT" -> raw.toLongOrNull() ?: raw
"NUMBER", "DECIMAL", "FLOAT", "DOUBLE" -> raw.replace(',', '.').toDoubleOrNull() ?: raw
"BOOLEAN", "BOOL" -> raw.lowercase() in setOf("true", "1", "si", "", "yes")
else -> raw
}
private fun dynamicHasPermission(context: Context, permission: String): Boolean =
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
private fun dynamicHasLocation(context: Context): Boolean =
dynamicHasPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ||
dynamicHasPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION)
private suspend fun currentDynamicGeo(context: Context): DynamicGeoSnapshot = suspendCancellableCoroutine { continuation ->
if (!dynamicHasLocation(context)) {
continuation.resumeWithException(SecurityException("Se necesita permiso de ubicación."))
return@suspendCancellableCoroutine
}
val source = CancellationTokenSource()
val client = LocationServices.getFusedLocationProviderClient(context)
try {
client.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token)
.addOnSuccessListener { location ->
if (!continuation.isActive) return@addOnSuccessListener
if (location == null) {
continuation.resumeWithException(IllegalStateException("No se pudo obtener una ubicación GPS actual."))
} else {
continuation.resume(DynamicGeoSnapshot(location.latitude, location.longitude, location.accuracy.toDouble()))
}
}
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
} catch (error: SecurityException) {
if (continuation.isActive) continuation.resumeWithException(error)
}
continuation.invokeOnCancellation { source.cancel() }
}
private fun newDynamicPhoto(context: Context): Pair<File, Uri> {
val directory = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES)
?: throw IllegalStateException("No se pudo acceder al almacenamiento de fotografías.")
directory.mkdirs()
val file = File.createTempFile("DH_FAST_${System.currentTimeMillis()}_", ".jpg", directory)
val uri = FileProvider.getUriForFile(context, "${context.packageName}.files", file)
return file to uri
}
private fun writeDynamicExif(file: File, geo: DynamicGeoSnapshot) {
val now = Instant.now()
val exif = ExifInterface(file)
exif.setLatLong(geo.latitude, geo.longitude)
val formatter = DateTimeFormatter.ofPattern("yyyy:MM:dd HH:mm:ss").withZone(ZoneId.systemDefault())
exif.setAttribute(ExifInterface.TAG_DATETIME_ORIGINAL, formatter.format(now))
exif.setAttribute(ExifInterface.TAG_DATETIME_DIGITIZED, formatter.format(now))
exif.saveAttributes()
}
private fun dynamicShortDate(value: String): String = value.replace('T', ' ').take(16)
@@ -86,10 +86,7 @@ fun F3VisitRoot(model: MainViewModel) {
inventoryMode = true
},
)
inventoryMode -> F3FieldInventoryScreen(model, onBack = {
inventoryMode = false
actsMode = true
})
inventoryMode -> F3FieldInventoryScreen(model, onBack = { inventoryMode = false })
else -> F3VisitOverview(
model = model,
onInventory = { inventoryMode = true },
@@ -122,9 +119,6 @@ private fun F3VisitOverview(
}
Text(visit.code, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
Text("${visit.operatorCompany?.name ?: "Sin operadora"} · ${visit.operationalArea?.name ?: "Sin área"}")
visit.scopeAsset?.let {
Text("Yacimiento: ${it.name}", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold)
}
visit.plannedStartAt?.let {
Text("Planificada: ${f3ShortDate(it)}", style = MaterialTheme.typography.bodySmall)
}
@@ -144,10 +138,10 @@ private fun F3VisitOverview(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Todo listo para comenzar", fontWeight = FontWeight.Bold)
Text("Al iniciar, pasás directamente a las Actas de esta inspección.")
Text("La inspección todavía no comenzó", fontWeight = FontWeight.Bold)
Text("Iniciarla registra fecha/hora real y tu usuario como actor de campo.")
Button(
onClick = { model.startVisit(); onActs() },
onClick = { model.startVisit() },
enabled = !model.busy,
modifier = Modifier.fillMaxWidth(),
) { Text(if (model.busy) "Iniciando…" else "Iniciar inspección") }
@@ -156,12 +150,12 @@ private fun F3VisitOverview(
}
if (visit.status == "IN_PROGRESS") {
Button(onClick = onActs, modifier = Modifier.fillMaxWidth()) {
val open = model.acts.count { it.status == "DRAFT" || it.status == "READY" }
Text("Abrir Actas · ${model.acts.size}${if (open > 0) " · $open abiertas" else ""}")
Button(onClick = onInventory, modifier = Modifier.fillMaxWidth()) {
Text("Abrir Inventario de campo")
}
OutlinedButton(onClick = onInventory, modifier = Modifier.fillMaxWidth()) {
Text("Inventario / Hallazgos")
OutlinedButton(onClick = onActs, modifier = Modifier.fillMaxWidth()) {
val open = model.acts.count { it.status == "DRAFT" || it.status == "READY" }
Text("Actas de la inspección · ${model.acts.size}${if (open > 0) " · $open abiertas" else ""}")
}
} else if (visit.status == "CLOSED") {
OutlinedButton(onClick = onActs, modifier = Modifier.fillMaxWidth()) {
@@ -169,7 +163,7 @@ private fun F3VisitOverview(
}
} else if (visit.status == "PLANNED") {
Text(
"Primero iniciá la inspección para habilitar Actas, Hallazgos y altas de campo.",
"Primero iniciá la inspección para habilitar altas, fotografías, Actas y Hallazgos.",
style = MaterialTheme.typography.bodySmall,
)
}
@@ -215,9 +209,7 @@ private fun F3FieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) {
var search by rememberSaveable(visit.id) { mutableStateOf("") }
var showCreate by rememberSaveable(visit.id) { mutableStateOf(false) }
var parentId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
var parentLabel by rememberSaveable(visit.id) {
mutableStateOf(visit.scopeAsset?.name ?: "Yacimiento de la inspección")
}
var parentLabel by rememberSaveable(visit.id) { mutableStateOf("Área de la inspección") }
var name by rememberSaveable(visit.id) { mutableStateOf("") }
var commonName by rememberSaveable(visit.id) { mutableStateOf("") }
var selectedTypeId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
@@ -331,10 +323,10 @@ private fun F3FieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) {
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedButton(onClick = onBack) { Text("Acta") }
OutlinedButton(onClick = onBack) { Text("Volver") }
Column(horizontalAlignment = Alignment.End) {
Text("Nuevo Hallazgo", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
Text("Elegí una Instalación o Subinstalación", style = MaterialTheme.typography.bodySmall)
Text("Inventario de campo", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
Text("Área → Yacimiento → Instalación Subinstalación", style = MaterialTheme.typography.bodySmall)
}
}
Column(Modifier.padding(horizontal = 16.dp)) { F3MessageStrip(model) }
@@ -418,62 +410,59 @@ private fun F3FieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) {
}
}
Card(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp)) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("¿Dónde encontraste el Hallazgo?", fontWeight = FontWeight.Bold)
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedTextField(
value = search,
onValueChange = { search = it },
label = { Text("Buscar instalación o subinstalación") },
modifier = Modifier.fillMaxWidth(),
label = { Text("Buscar por nombre o código") },
modifier = Modifier.weight(1f),
singleLine = true,
)
Spacer(Modifier.width(8.dp))
Button(onClick = { model.searchInventory(search) }, enabled = !model.busy) { Text("Buscar") }
}
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
Modifier.fillMaxWidth().padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Button(
onClick = { model.searchInventory(search) },
enabled = !model.busy,
modifier = Modifier.weight(1f),
) { Text("Buscar") }
OutlinedButton(
onClick = {
Column {
Text("Alta en campo", fontWeight = FontWeight.Bold)
Text("Padre: $parentLabel", style = MaterialTheme.typography.bodySmall)
}
OutlinedButton(onClick = {
showCreate = !showCreate
if (showCreate) model.loadFieldTypes(parentId)
},
modifier = Modifier.weight(1f),
) { Text(if (showCreate) "Cancelar" else "+ Agregar") }
}
}
}) { Text(if (showCreate) "Ocultar" else "Agregar") }
}
if (showCreate) {
Card(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp)) {
Column(
Modifier
.fillMaxWidth()
.padding(12.dp)
.padding(horizontal = 16.dp)
.verticalScroll(rememberScrollState())
.weight(1f, fill = false),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text("Nueva alta de campo", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text("Ubicación: $parentLabel", style = MaterialTheme.typography.bodySmall)
if (parentId != null) {
OutlinedButton(onClick = {
parentId = null
parentLabel = visit.scopeAsset?.name ?: "Yacimiento de la inspección"
parentLabel = "Área de la inspección"
resetCreateForm()
selectedTypeId = null
model.loadFieldTypes(null)
}) { Text("Volver al Yacimiento") }
}) { Text("Volver al Área") }
}
if (model.fieldTypes.isEmpty()) {
Text("No hay un tipo disponible para esta ubicación.")
Text("Este nivel no admite más hijos estructurales.")
} else {
Text("Tipo de registro", style = MaterialTheme.typography.bodySmall)
Text("Vas a crear", style = MaterialTheme.typography.bodySmall)
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(model.fieldTypes, key = { it.id }) { type ->
AssistChip(
@@ -489,9 +478,9 @@ private fun F3FieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) {
}
if (selectedType?.familyRequired == true) {
Text("Clasificación", fontWeight = FontWeight.Bold)
Text("Familia técnica", fontWeight = FontWeight.Bold)
Text(
"Elegí el tipo técnico. Si no está catalogado, usá Otro / no catalogado.",
"Elegí la que corresponda al Excel. Si no existe, usá Otro / no catalogado; nunca quedás bloqueado.",
style = MaterialTheme.typography.bodySmall,
)
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
@@ -512,7 +501,7 @@ private fun F3FieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) {
selectedFamily?.let { family ->
if (family.isOther) {
Text(
"Se registrará como no catalogado para revisión posterior en oficina.",
"Se registrará como familia no catalogada para revisión posterior en oficina.",
color = MaterialTheme.colorScheme.secondary,
style = MaterialTheme.typography.bodySmall,
)
@@ -529,7 +518,7 @@ private fun F3FieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) {
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Nombre o código identificable *") },
label = { Text("Nombre identificable *") },
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
@@ -574,26 +563,17 @@ private fun F3FieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) {
},
enabled = selectedType != null && name.isNotBlank() && attributesReady && familyReady && !model.busy,
modifier = Modifier.fillMaxWidth(),
) { Text("Guardar alta y capturar GPS") }
}
) { Text("Capturar GPS y crear") }
HorizontalDivider()
}
}
Text(
"Instalaciones y subinstalaciones",
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
fontWeight = FontWeight.Bold,
)
Text("Estructura disponible", modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), fontWeight = FontWeight.Bold)
LazyColumn(
Modifier.fillMaxSize().padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(
model.inventory.filter { candidate ->
candidate.type?.let(::f3TypeCode) in setOf("instalacion", "subinstalacion")
},
key = { it.id },
) { item ->
items(model.inventory, key = { it.id }) { item ->
F3InventoryCard(
item = item,
onInspect = { model.selectExisting(item) },
@@ -631,10 +611,10 @@ private fun F3CaptureCard(
if (captureRequired) {
Text("GPS de alta: ${if (gps) "OK" else "pendiente"} · Fotos: $photos")
if (!ready) {
Text("Antes de registrar el Hallazgo, completá GPS + foto.", color = MaterialTheme.colorScheme.error)
Text("Antes de registrar Hallazgos, completá GPS + foto.", color = MaterialTheme.colorScheme.error)
Button(onClick = onPhoto, modifier = Modifier.fillMaxWidth()) { Text("Tomar foto obligatoria") }
} else {
Text("Captura completa · listo para el Hallazgo", color = MaterialTheme.colorScheme.primary)
Text("Captura completa · listo para Hallazgos", color = MaterialTheme.colorScheme.primary)
}
} else {
Text("Registro existente seleccionado.", style = MaterialTheme.typography.bodySmall)
@@ -651,8 +631,8 @@ private fun F3InventoryCard(
) {
val typeCode = item.type?.let(::f3TypeCode).orEmpty()
val canHaveFinding = typeCode in setOf("instalacion", "subinstalacion")
val canHaveChild = typeCode == "instalacion"
val childLabel = "+ Agregar subinstalación"
val canHaveChild = typeCode in setOf("yacimiento", "instalacion")
val childLabel = if (typeCode == "yacimiento") "Agregar instalación aquí" else "Agregar subinstalación aquí"
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
@@ -666,12 +646,12 @@ private fun F3InventoryCard(
}
if (canHaveFinding) {
Text(
if (item.readyForFinding) "Disponible" else "GPS/foto pendiente",
if (item.readyForFinding) "Disponible para Hallazgos" else "GPS/foto pendiente",
color = if (item.readyForFinding) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
Button(onClick = onInspect, modifier = Modifier.fillMaxWidth()) {
Text(if (item.selectedInInspection) "Seleccionar para Hallazgo" else "Usar para Hallazgo")
OutlinedButton(onClick = onInspect, modifier = Modifier.fillMaxWidth()) {
Text(if (item.selectedInInspection) "Abrir Hallazgos" else "Usar en esta inspección")
}
}
if (canHaveChild) {
@@ -185,7 +185,7 @@ fun FieldFindingScreen(model: MainViewModel) {
Text(asset.name, fontWeight = FontWeight.Bold)
Text("${asset.code} · Acta ${options.act.code}", style = MaterialTheme.typography.bodySmall)
Text(
"Ubicación y foto del Inventario: ${if (options.capture.readyForFinding) "completas" else "pendientes"}",
"GPS + foto del Inventario: ${if (options.capture.readyForFinding) "OK" else "pendiente"}",
color = if (options.capture.readyForFinding) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
@@ -210,10 +210,7 @@ fun FieldFindingScreen(model: MainViewModel) {
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text("${finding.code} · ${finding.title}", fontWeight = FontWeight.SemiBold)
Text(
"Gravedad: ${finding.severity ?: "sin definir"} · ${findingStatusLabel(finding.status)}",
style = MaterialTheme.typography.bodySmall,
)
Text("Gravedad: ${finding.severity ?: "s/d"} · ${finding.status}", style = MaterialTheme.typography.bodySmall)
Text(
"Evidencias: ${evidence.size} · Fotos: ${evidence.count { it.kind == "PHOTO" }}",
style = MaterialTheme.typography.bodySmall,
@@ -229,7 +226,7 @@ fun FieldFindingScreen(model: MainViewModel) {
enabled = !model.busy,
modifier = Modifier.fillMaxWidth(),
) {
Text("Tomar foto con ubicación")
Text("Tomar foto con GPS")
}
}
}
@@ -369,7 +366,7 @@ fun FieldFindingScreen(model: MainViewModel) {
enabled = !model.busy,
modifier = Modifier.fillMaxWidth(),
) {
Text("Tomar foto con ubicación")
Text("Tomar foto con GPS")
}
Text("También podés registrar otro Hallazgo sobre el mismo Inventario.", style = MaterialTheme.typography.bodySmall)
}
@@ -378,17 +375,6 @@ fun FieldFindingScreen(model: MainViewModel) {
}
}
private fun findingStatusLabel(status: String): String = when (status) {
"OPEN" -> "Abierto"
"COMPANY_RESPONDED" -> "Respondido por la empresa"
"UNDER_REVIEW" -> "En revisión"
"VERIFICATION_PENDING" -> "Verificación pendiente"
"READY_TO_CLOSE" -> "Listo para cierre"
"CLOSED" -> "Cerrado"
"CANCELLED" -> "Cancelado"
else -> "Estado no reconocido"
}
private fun findingHasPermission(context: Context, permission: String): Boolean =
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
@@ -407,7 +393,7 @@ private suspend fun currentFindingGeo(context: Context): FindingGeoSnapshot = su
client.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token)
.addOnSuccessListener { location ->
if (!continuation.isActive) return@addOnSuccessListener
if (location == null) continuation.resumeWithException(IllegalStateException("No se pudo obtener una ubicación actual."))
if (location == null) continuation.resumeWithException(IllegalStateException("No se pudo obtener una ubicación GPS actual."))
else continuation.resume(FindingGeoSnapshot(location.latitude, location.longitude, location.accuracy.toDouble()))
}
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
@@ -1,32 +0,0 @@
package com.korexlabs.dhinspeccion.ui
import com.korexlabs.dhinspeccion.MainViewModel
import com.korexlabs.dhinspeccion.data.FieldType
/**
* Compatibilidad de compilación para pantallas heredadas que aún forman parte del árbol Android,
* aunque la navegación productiva use ModernVisitRoot.
*/
internal fun MainViewModel.createFieldAsset(
type: FieldType,
parentId: String?,
familyId: String?,
name: String,
commonName: String?,
attributes: Map<String, Any?>,
latitude: Double,
longitude: Double,
accuracyM: Double?,
) = createFieldAsset(
type = type,
parentId = parentId,
familyId = familyId,
name = name,
commonName = commonName,
description = null,
discoveryNotes = null,
attributes = attributes,
latitude = latitude,
longitude = longitude,
accuracyM = accuracyM,
)
@@ -3,14 +3,12 @@ package com.korexlabs.dhinspeccion.ui
import android.content.Context
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
@@ -32,7 +30,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
@@ -40,7 +37,6 @@ import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
import com.korexlabs.dhinspeccion.MainViewModel
import com.korexlabs.dhinspeccion.R
private const val BIOMETRIC_PREFS = "dh_v2_biometric"
private const val BIOMETRIC_ENABLED = "enabled"
@@ -78,7 +74,7 @@ fun DhRoot(model: MainViewModel, activity: FragmentActivity) {
if (model.session == null) unlocked = false
}
DhTheme {
MaterialTheme {
Surface(Modifier.fillMaxSize()) {
when {
model.session == null -> EnhancedLoginScreen(model) {
@@ -95,30 +91,13 @@ fun DhRoot(model: MainViewModel, activity: FragmentActivity) {
},
)
model.fieldFindingOptions != null -> FieldFindingScreen(model)
model.visit != null -> ModernVisitRoot(model)
model.visit != null -> F3VisitRoot(model)
else -> MobileHomeScreen(model)
}
}
}
}
@Composable
private fun MendozaBrand() {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Image(
painter = painterResource(R.drawable.logo_mendoza_oficial),
contentDescription = "Gobierno de Mendoza",
modifier = Modifier.size(156.dp),
)
Text("DH Inspección", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
Text("Dirección de Hidrocarburos · Aplicación de campo")
}
}
@Composable
private fun EnhancedLoginScreen(model: MainViewModel, onPasswordLogin: () -> Unit) {
var identifier by rememberSaveable { mutableStateOf("") }
@@ -127,13 +106,14 @@ private fun EnhancedLoginScreen(model: MainViewModel, onPasswordLogin: () -> Uni
Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(14.dp)) {
MendozaBrand()
Text("DH Inspección", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
Text("Aplicación de campo · Dirección de Hidrocarburos")
model.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
model.notice?.let { Text(it, color = MaterialTheme.colorScheme.primary) }
OutlinedTextField(
value = identifier,
onValueChange = { identifier = it },
label = { Text("Usuario o correo electrónico") },
label = { Text("Usuario o email") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
@@ -218,11 +198,6 @@ private fun BiometricUnlockScreen(
verticalArrangement = Arrangement.spacedBy(14.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Image(
painter = painterResource(R.drawable.logo_mendoza_oficial),
contentDescription = "Gobierno de Mendoza",
modifier = Modifier.size(120.dp),
)
Text("DH Inspección", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
Text("Ingresá con tu huella")
error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
@@ -126,14 +126,13 @@ fun MobileActsScreen(
Text("Actas", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
}
Text("${visit.code} · ${visit.operatorCompany?.name.orEmpty()}")
visit.scopeAsset?.let { Text("Yacimiento: ${it.name}", style = MaterialTheme.typography.bodySmall) }
F32ActMessage(model)
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Actas de esta inspección", fontWeight = FontWeight.Bold)
Text("Actas de esta Inspección", fontWeight = FontWeight.Bold)
if (model.acts.isEmpty()) {
Text("Todavía no hay Actas. Creá la primera y después agregá los Hallazgos.")
Text("Todavía no hay Actas. La primera se inicia sobre una Instalación/Subinstalación seleccionada.")
}
model.acts.forEach { act ->
val active = selected?.id == act.id
@@ -157,15 +156,15 @@ fun MobileActsScreen(
Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text("Nueva Acta", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Nueva Acta", fontWeight = FontWeight.Bold)
if (model.acts.any { it.status == "LOCKED" }) {
Text(
"Podés abrir una nueva Acta aunque otra esté BLOQUEADA esperando firmas. Sólo se permite un borrador a la vez.",
style = MaterialTheme.typography.bodySmall,
)
}
Text("Urgencia", fontWeight = FontWeight.Bold)
Text("Urgencia del Acta", fontWeight = FontWeight.Bold)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
AssistChip(
onClick = { newActUrgency = "NON_URGENT" },
@@ -184,18 +183,23 @@ fun MobileActsScreen(
},
style = MaterialTheme.typography.bodySmall,
)
Text(
"Abrí el Acta primero. Después elegís la Instalación o Subinstalación al agregar cada Hallazgo.",
style = MaterialTheme.typography.bodySmall,
)
val selectedInventory = model.selectedFieldAsset?.asset
if (selectedInventory == null) {
Text("Primero elegí una Instalación o Subinstalación desde Inventario de campo. Ese registro será el primer elemento del Acta.")
Button(onClick = onGoInventory, modifier = Modifier.fillMaxWidth()) {
Text("Ir a Inventario y elegir")
}
} else {
Text("Inventario inicial: ${selectedInventory.name} · ${selectedInventory.code}")
Button(
onClick = { model.createAct(newActUrgency) },
onClick = { model.createActForSelectedInventory(newActUrgency) },
enabled = !model.busy,
modifier = Modifier.fillMaxWidth(),
) { Text("Crear nueva Acta") }
}
}
}
}
if (selected != null) {
HorizontalDivider()
@@ -216,21 +220,7 @@ fun MobileActsScreen(
when (selected.status) {
"DRAFT" -> {
Card(
Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Hallazgos", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text("Buscá una Instalación o Subinstalación existente. Si no está, podés agregarla en campo en el mismo flujo.")
Button(onClick = onGoInventory, modifier = Modifier.fillMaxWidth()) {
Text("+ Agregar Hallazgo")
}
}
}
HorizontalDivider()
Text("Responsable de la empresa", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text("1. Responsable de la empresa", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
AssistChip(onClick = { attendance = "PRESENT" }, label = { Text(if (attendance == "PRESENT") "✓ Presente" else "Presente") })
AssistChip(onClick = { attendance = "ABSENT" }, label = { Text(if (attendance == "ABSENT") "✓ Ausente" else "Ausente") })
@@ -269,7 +259,12 @@ fun MobileActsScreen(
}
HorizontalDivider()
Text("Finalizar contenido", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text("2. Hallazgos / verificaciones", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text("Los Hallazgos se cargan desde Inventario y quedan vinculados explícitamente a ${selected.code}. El Acta también puede finalizar sin Hallazgos cuando corresponde dejar constancia de una inspección o verificación sin nuevos incumplimientos.")
Button(onClick = onGoInventory, modifier = Modifier.fillMaxWidth()) { Text("Ir a Inventario / Hallazgos") }
HorizontalDivider()
Text("3. Finalizar contenido", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text("Al BLOQUEAR el Acta, el contenido y los Hallazgos quedan inmutables. Esta acción no se puede deshacer.")
Button(
onClick = { model.prepareSelectedAct() },
@@ -11,30 +11,79 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Assignment
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.korexlabs.dhinspeccion.MainViewModel
import com.korexlabs.dhinspeccion.data.DhRepository
import com.korexlabs.dhinspeccion.data.MobileInspectionOpenRepository
import com.korexlabs.dhinspeccion.data.MobilePlanningAsset
import com.korexlabs.dhinspeccion.data.VisitSummary
import kotlinx.coroutines.launch
@Composable
fun MobileHomeScreen(model: MainViewModel) {
val session = model.session ?: return
val fieldVisits = model.visits.filter { it.status != "DRAFT" }
val context = LocalContext.current
val scope = rememberCoroutineScope()
val openRepository = remember(context) { MobileInspectionOpenRepository(context) }
var showOpen by rememberSaveable { mutableStateOf(false) }
var areas by remember { mutableStateOf<List<MobilePlanningAsset>>(emptyList()) }
var operators by remember { mutableStateOf<List<MobilePlanningAsset>>(emptyList()) }
var selectedAreaId by rememberSaveable { mutableStateOf<String?>(null) }
var selectedCompanyId by rememberSaveable { mutableStateOf<String?>(null) }
var loadingContext by remember { mutableStateOf(false) }
var opening by remember { mutableStateOf(false) }
var localError by remember { mutableStateOf<String?>(null) }
fun loadAreas() {
scope.launch {
loadingContext = true
localError = null
runCatching { openRepository.areas().data }
.onSuccess { areas = it }
.onFailure { localError = DhRepository.humanError(it) }
loadingContext = false
}
}
fun chooseArea(areaId: String) {
selectedAreaId = areaId
selectedCompanyId = null
operators = emptyList()
scope.launch {
loadingContext = true
localError = null
runCatching { openRepository.operators(areaId).data }
.onSuccess { operators = it }
.onFailure { localError = DhRepository.humanError(it) }
loadingContext = false
}
}
LaunchedEffect(showOpen) {
if (showOpen && areas.isEmpty()) loadAreas()
}
Column(Modifier.fillMaxSize().padding(top = 28.dp)) {
Row(
@@ -42,18 +91,16 @@ fun MobileHomeScreen(model: MainViewModel) {
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(Modifier.weight(1f)) {
Column {
Text("Mis inspecciones", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
Text(session.displayName, style = MaterialTheme.typography.bodySmall)
}
Row {
OutlinedButton(onClick = { model.loadVisits() }, enabled = !model.busy) {
Icon(Icons.Filled.Refresh, contentDescription = null)
Spacer(Modifier.width(6.dp))
OutlinedButton(onClick = { model.loadVisits() }, enabled = !model.busy && !opening) {
Text("Actualizar")
}
Spacer(Modifier.width(8.dp))
OutlinedButton(onClick = { model.logout() }) { Text("Salir") }
OutlinedButton(onClick = { model.logout() }, enabled = !opening) { Text("Salir") }
}
}
@@ -63,49 +110,97 @@ fun MobileHomeScreen(model: MainViewModel) {
) {
model.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
model.notice?.let { Text(it, color = MaterialTheme.colorScheme.primary) }
localError?.let { Text(it, color = MaterialTheme.colorScheme.error) }
Surface(
Button(
onClick = {
showOpen = !showOpen
localError = null
},
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.secondaryContainer,
enabled = !opening,
) {
Row(
Modifier.padding(14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
Text(if (showOpen) "Cancelar nueva inspección" else "Abrir inspección")
}
if (showOpen) {
Card(Modifier.fillMaxWidth()) {
Column(
Modifier.fillMaxWidth().padding(14.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Icon(Icons.Filled.Assignment, contentDescription = null, tint = MaterialTheme.colorScheme.secondary)
Column(Modifier.weight(1f)) {
Text("Trabajo planificado desde oficina", fontWeight = FontWeight.Bold)
Text("Abrir inspección en campo", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text(
"Acá aparecen las inspecciones ya planificadas y asignadas desde el tablero. La visita se inicia únicamente al llegar al lugar.",
"Elegí el Área y la Operadora vigente. La inspección se crea autoasignada a vos y queda iniciada con la fecha y hora del servidor.",
style = MaterialTheme.typography.bodySmall,
)
Text("1. Área", fontWeight = FontWeight.SemiBold)
if (loadingContext && areas.isEmpty()) CircularProgressIndicator()
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(areas, key = { it.id }) { area ->
AssistChip(
onClick = { chooseArea(area.id) },
label = { Text(if (area.id == selectedAreaId) "${area.name}" else area.name) },
)
}
}
if (selectedAreaId != null) {
Text("2. Operadora", fontWeight = FontWeight.SemiBold)
if (loadingContext && operators.isEmpty()) {
CircularProgressIndicator()
} else if (operators.isEmpty()) {
Text("No hay una Operadora vigente para el Área seleccionada.", color = MaterialTheme.colorScheme.error)
}
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(operators, key = { it.id }) { company ->
AssistChip(
onClick = { selectedCompanyId = company.id },
label = { Text(if (company.id == selectedCompanyId) "${company.name}" else company.name) },
)
}
}
}
Button(
onClick = {
val areaId = selectedAreaId ?: return@Button
val companyId = selectedCompanyId ?: return@Button
scope.launch {
opening = true
localError = null
runCatching { openRepository.open(areaId, companyId) }
.onSuccess { opened ->
showOpen = false
model.openVisit(opened.id)
}
.onFailure { localError = DhRepository.humanError(it) }
opening = false
}
},
modifier = Modifier.fillMaxWidth(),
enabled = selectedAreaId != null && selectedCompanyId != null && !opening && !loadingContext,
) {
Text(if (opening) "Abriendo…" else "Abrir inspección ahora")
}
}
}
}
}
if (model.busy && fieldVisits.isEmpty()) {
if (model.busy && model.visits.isEmpty()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
} else if (fieldVisits.isEmpty()) {
Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(10.dp)) {
Icon(Icons.Filled.Assignment, contentDescription = null, tint = MaterialTheme.colorScheme.secondary)
Text("No tenés inspecciones listas para campo", fontWeight = FontWeight.Bold)
Text(
"Cuando una inspección quede planificada y asignada desde el tablero, aparecerá en esta pantalla.",
style = MaterialTheme.typography.bodyMedium,
)
Button(onClick = { model.loadVisits() }, enabled = !model.busy) { Text("Actualizar lista") }
}
} else if (model.visits.isEmpty()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("No tenés inspecciones asignadas. Podés abrir una nueva desde esta tablet.")
}
} else {
LazyColumn(
Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 10.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
items(fieldVisits, key = { it.id }) { visit ->
items(model.visits, key = { it.id }) { visit ->
MobileVisitCard(visit) { model.openVisit(visit.id) }
}
item { Spacer(Modifier.height(24.dp)) }
@@ -116,33 +211,21 @@ fun MobileHomeScreen(model: MainViewModel) {
@Composable
private fun MobileVisitCard(visit: VisitSummary, onOpen: () -> Unit) {
ElevatedCard(onClick = onOpen, modifier = Modifier.fillMaxWidth()) {
Column(Modifier.padding(15.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Card(onClick = onOpen, modifier = Modifier.fillMaxWidth()) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(visit.code, fontWeight = FontWeight.Bold)
StatusPill(mobileVisitStatusLabel(visit.status))
Text(visit.status)
}
Text(visit.scopeAsset?.name ?: visit.operationalArea?.name ?: "Alcance sin informar", fontWeight = FontWeight.SemiBold)
Text(visit.operatorCompany?.name ?: "Operadora sin definir")
visit.operationalArea?.name?.takeIf { it != visit.scopeAsset?.name }?.let {
Text("Área · $it", style = MaterialTheme.typography.bodySmall)
}
Text(visit.operationalArea?.name ?: "Área sin definir", style = MaterialTheme.typography.bodySmall)
visit.plannedStartAt?.let {
Text("Planificada · ${it.replace('T', ' ').take(16)}", style = MaterialTheme.typography.bodySmall)
Text("Prevista: ${it.replace('T', ' ').take(16)}", style = MaterialTheme.typography.bodySmall)
}
Text(
"Inventario previsto: ${visit.assetCount} · Equipo inspector: ${visit.memberCount}",
"Inventario: ${visit.assetCount} · Equipo inspector: ${visit.memberCount}",
style = MaterialTheme.typography.bodySmall,
)
}
}
}
internal fun mobileVisitStatusLabel(status: String): String = when (status) {
"DRAFT" -> "Borrador"
"PLANNED" -> "Planificada"
"IN_PROGRESS" -> "En curso"
"CLOSED" -> "Cerrada"
"CANCELLED" -> "Cancelada"
else -> "Estado no reconocido"
}
@@ -1,590 +0,0 @@
package com.korexlabs.dhinspeccion.ui
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Assignment
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.EditNote
import androidx.compose.material.icons.filled.ErrorOutline
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Person
import androidx.compose.material3.Button
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import com.google.android.gms.location.LocationServices
import com.google.android.gms.location.Priority
import com.google.android.gms.tasks.CancellationTokenSource
import com.korexlabs.dhinspeccion.MainViewModel
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import java.io.File
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
private data class ModernActSignatureGeo(
val latitude: Double,
val longitude: Double,
val accuracyM: Double?,
)
@Composable
fun ModernMobileActsScreen(
model: MainViewModel,
onBack: () -> Unit,
onGoInventory: () -> Unit,
) {
val visit = model.visit ?: return
val selected = model.selectedAct
val closure = model.actClosure
val context = LocalContext.current
val scope = rememberCoroutineScope()
var newActUrgency by rememberSaveable(visit.id) { mutableStateOf("NON_URGENT") }
var attendance by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.attendanceStatus ?: "PRESENT") }
var fullName by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.fullName.orEmpty()) }
var documentType by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.documentType ?: "DNI") }
var documentNumber by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.documentNumber.orEmpty()) }
var position by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.position.orEmpty()) }
var email by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.email.orEmpty()) }
var phone by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.phone.orEmpty()) }
var absenceReason by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.absenceReason.orEmpty()) }
var refusalReason by rememberSaveable(selected?.id) { mutableStateOf("") }
var manifestation by rememberSaveable(selected?.id) { mutableStateOf("CONFORMITY") }
var dissentStatement by rememberSaveable(selected?.id) { mutableStateOf("") }
LaunchedEffect(visit.id) { model.reloadActs() }
fun signWithGeo(file: File, company: Boolean) {
scope.launch {
val geo = runCatching { currentModernActSignatureGeo(context) }.getOrNull()
if (company) {
model.signSelectedActAsCompany(
png = file,
latitude = geo?.latitude,
longitude = geo?.longitude,
accuracyM = geo?.accuracyM,
manifestation = manifestation,
statement = dissentStatement.takeIf { manifestation == "DISSENT" },
)
} else {
model.signSelectedActAsInspector(
png = file,
latitude = geo?.latitude,
longitude = geo?.longitude,
accuracyM = geo?.accuracyM,
)
}
}
}
Column(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 18.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
ModernHeader(title = "Actas", subtitle = visit.code, onBack = onBack)
Surface(
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.large,
tonalElevation = 1.dp,
) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
Text(
visit.scopeAsset?.name ?: "Yacimiento",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
visit.operatorCompany?.name?.takeIf { it.isNotBlank() }?.let {
Text(
it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
}
ModernMessage(model)
if (model.acts.isEmpty()) {
ElevatedCard(Modifier.fillMaxWidth()) {
Column(
Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(Icons.Filled.Assignment, contentDescription = null, tint = MaterialTheme.colorScheme.secondary)
Text("Todavía no hay Actas", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text(
"Creá la primera Acta y después incorporá Hallazgos seleccionando el Inventario.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
} else {
Text("Actas de esta inspección", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
model.acts.forEach { act ->
val active = selected?.id == act.id
Surface(
onClick = { model.selectAct(act.id) },
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.large,
color = if (active) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface,
tonalElevation = if (active) 1.dp else 0.dp,
border = BorderStroke(
if (active) 2.dp else 1.dp,
if (active) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant,
),
) {
Row(
Modifier.padding(15.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(
when (act.status) {
"LOCKED" -> Icons.Filled.Lock
"SEALED" -> Icons.Filled.CheckCircle
else -> Icons.Filled.EditNote
},
contentDescription = null,
tint = if (active) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.secondary,
)
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(act.code, fontWeight = FontWeight.Bold)
Text(
"${modernActStatusLabel(act.status)} · ${if (act.urgency == "URGENT") "Urgente" else "No urgente"}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
"${act.findingCount} Hallazgo${if (act.findingCount == 1) "" else "s"} · ${act.assetCount} elemento${if (act.assetCount == 1) "" else "s"}",
style = MaterialTheme.typography.bodySmall,
)
}
if (active) Icon(Icons.Filled.CheckCircle, "Seleccionada", tint = MaterialTheme.colorScheme.primary)
}
}
}
}
val hasDraftAct = model.acts.any { it.status == "DRAFT" }
if (visit.status == "IN_PROGRESS" && !hasDraftAct) {
ElevatedCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Surface(shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.primaryContainer) {
Icon(Icons.Filled.Add, null, Modifier.padding(10.dp), tint = MaterialTheme.colorScheme.primary)
}
Spacer(Modifier.width(10.dp))
Column {
Text("Nueva Acta", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text("Un solo borrador activo por vez", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
Text("Urgencia", fontWeight = FontWeight.SemiBold)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(
selected = newActUrgency == "NON_URGENT",
onClick = { newActUrgency = "NON_URGENT" },
label = { Text("No urgente") },
)
FilterChip(
selected = newActUrgency == "URGENT",
onClick = { newActUrgency = "URGENT" },
label = { Text("Urgente") },
)
}
Surface(
Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.surfaceVariant,
) {
Text(
if (newActUrgency == "URGENT") {
"El plazo urgente se computará conforme a la política institucional vigente al cerrar el contenido del Acta."
} else {
"El plazo no urgente comienza con la oficialización GEDO, no al crear el Acta."
},
Modifier.padding(12.dp),
style = MaterialTheme.typography.bodySmall,
)
}
Button(
onClick = { model.createAct(newActUrgency) },
enabled = !model.busy,
modifier = Modifier.fillMaxWidth(),
) {
Icon(Icons.Filled.Add, null)
Spacer(Modifier.width(7.dp))
Text(if (model.busy) "Creando…" else "Crear nueva Acta")
}
}
}
}
if (selected != null) {
HorizontalDivider()
Surface(
Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceVariant,
) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
Text(selected.code, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
StatusPill(modernActStatusLabel(selected.status))
}
Text(if (selected.urgency == "URGENT") "Urgente" else "No urgente", color = MaterialTheme.colorScheme.onSurfaceVariant)
Text("${selected.findingCount} Hallazgos · ${selected.assetCount} elementos de Inventario", style = MaterialTheme.typography.bodySmall)
selected.deadlineAt?.let { Text("Vencimiento · $it", style = MaterialTheme.typography.bodySmall) }
if (selected.deadlineAt == null && selected.deadlineBasis == "GEDO_DATE") {
Text("Vencimiento pendiente de fecha GEDO", style = MaterialTheme.typography.bodySmall)
}
}
}
when (selected.status) {
"DRAFT" -> {
ElevatedCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text("Hallazgos", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text(
"Elegí una Instalación o Subinstalación existente, o creala en campo si todavía no está registrada.",
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Button(onClick = onGoInventory, modifier = Modifier.fillMaxWidth()) {
Icon(Icons.Filled.Add, null)
Spacer(Modifier.width(6.dp))
Text("Agregar Hallazgo")
}
}
}
ElevatedCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Filled.Person, null, tint = MaterialTheme.colorScheme.primary)
Spacer(Modifier.width(8.dp))
Text("Responsable de la empresa", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(selected = attendance == "PRESENT", onClick = { attendance = "PRESENT" }, label = { Text("Presente") })
FilterChip(selected = attendance == "ABSENT", onClick = { attendance = "ABSENT" }, label = { Text("Ausente") })
}
if (attendance == "PRESENT") {
OutlinedTextField(fullName, { fullName = it }, label = { Text("Nombre y apellido *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
listOf("DNI", "CUIL", "PASSPORT", "OTHER").forEach { kind ->
FilterChip(
selected = documentType == kind,
onClick = { documentType = kind },
label = { Text(documentTypeLabel(kind)) },
)
}
}
OutlinedTextField(documentNumber, { documentNumber = it }, label = { Text("Documento *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
OutlinedTextField(position, { position = it }, label = { Text("Cargo *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
OutlinedTextField(email, { email = it }, label = { Text("Correo electrónico") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
OutlinedTextField(phone, { phone = it }, label = { Text("Teléfono") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
Button(
onClick = { model.setCompanyResponsiblePresent(fullName, documentType, documentNumber, position, email, phone) },
enabled = !model.busy && fullName.isNotBlank() && documentNumber.isNotBlank() && position.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
) { Text("Guardar responsable") }
} else {
OutlinedTextField(
absenceReason,
{ absenceReason = it },
label = { Text("Motivo de ausencia *") },
minLines = 2,
modifier = Modifier.fillMaxWidth(),
)
Button(
onClick = { model.setCompanyResponsibleAbsent(absenceReason) },
enabled = !model.busy && absenceReason.trim().length >= 10,
modifier = Modifier.fillMaxWidth(),
) { Text("Guardar ausencia") }
}
}
}
ElevatedCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Filled.Lock, null, tint = MaterialTheme.colorScheme.error)
Spacer(Modifier.width(8.dp))
Text("Cerrar Acta", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
}
Text(
"Al cerrar el Acta, su contenido y sus Hallazgos quedan inmutables. El Acta quedará pendiente de firma.",
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Button(
onClick = { model.prepareSelectedAct() },
enabled = !model.busy && closure?.responsible != null,
modifier = Modifier.fillMaxWidth(),
) { Text("Cerrar Acta y dejar pendiente de firma") }
if (closure?.responsible == null) {
Text(
"Antes de cerrar, registrá al responsable de la empresa o su ausencia.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
"LOCKED" -> {
val signatures = closure?.signatures.orEmpty()
val inspectorSigned = signatures.any { it.signerType == "INSPECTOR" && it.status == "SIGNED" }
val companyOutcome = signatures.firstOrNull { it.signerType == "COMPANY_RESPONSIBLE" }
val companyResolved = companyOutcome?.status in setOf("SIGNED", "REFUSED", "ABSENT")
ElevatedCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Filled.Lock, null, tint = MaterialTheme.colorScheme.secondary)
Spacer(Modifier.width(8.dp))
Text("Acta pendiente de firma", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
}
Text("El contenido ya es inmutable. Completá las firmas o dejá asentada la ausencia/negativa correspondiente para finalizarla.")
closure?.closure?.preparedSha256?.let { Text("SHA-256 de cierre · $it", style = MaterialTheme.typography.bodySmall) }
HorizontalDivider()
Text("Firma del inspector", fontWeight = FontWeight.Bold)
if (inspectorSigned) {
SuccessLine("Firma del inspector registrada")
} else {
Text(closure?.consents?.inspector.orEmpty(), style = MaterialTheme.typography.bodySmall)
SignaturePad(
label = "Firmá como inspector/a",
enabled = !model.busy,
onCaptured = { file -> signWithGeo(file, company = false) },
)
}
HorizontalDivider()
Text("Manifestación de la empresa", fontWeight = FontWeight.Bold)
if (companyOutcome != null && companyResolved) {
val detail = when (companyOutcome.status) {
"SIGNED" -> if (companyOutcome.companyManifestation == "DISSENT") "Firma en disidencia" else "Firma en conformidad"
"REFUSED" -> "Negativa a firmar registrada"
"ABSENT" -> "Ausencia para la firma registrada"
else -> "Manifestación registrada"
}
SuccessLine(detail)
companyOutcome.reason?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
companyOutcome.companyStatement?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
} else if (closure?.responsible?.attendanceStatus == "ABSENT") {
val savedAbsenceReason = closure.responsible.absenceReason.orEmpty()
Surface(Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.secondaryContainer) {
Row(Modifier.padding(12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Filled.ErrorOutline, null, tint = MaterialTheme.colorScheme.secondary)
Column(Modifier.weight(1f)) {
Text("El responsable fue registrado como ausente.", fontWeight = FontWeight.SemiBold)
if (savedAbsenceReason.isNotBlank()) Text(savedAbsenceReason, style = MaterialTheme.typography.bodySmall)
}
}
}
OutlinedButton(
onClick = { model.recordCompanyOutcome("ABSENT", savedAbsenceReason) },
enabled = !model.busy && inspectorSigned && savedAbsenceReason.trim().length >= 10,
modifier = Modifier.fillMaxWidth(),
) { Text("Registrar ausencia para la firma") }
} else {
Text(closure?.consents?.company.orEmpty(), style = MaterialTheme.typography.bodySmall)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(selected = manifestation == "CONFORMITY", onClick = { manifestation = "CONFORMITY" }, label = { Text("Conforme") })
FilterChip(selected = manifestation == "DISSENT", onClick = { manifestation = "DISSENT" }, label = { Text("En disidencia") })
}
if (manifestation == "DISSENT") {
OutlinedTextField(
dissentStatement,
{ dissentStatement = it },
label = { Text("Manifestación de disidencia *") },
minLines = 2,
modifier = Modifier.fillMaxWidth(),
)
}
SignaturePad(
label = "Firma del responsable de empresa",
enabled = !model.busy && inspectorSigned && (manifestation != "DISSENT" || dissentStatement.trim().length >= 10),
onCaptured = { file -> signWithGeo(file, company = true) },
)
Text("Si se niega a firmar, registrá el motivo.", style = MaterialTheme.typography.bodySmall)
OutlinedTextField(
refusalReason,
{ refusalReason = it },
label = { Text("Motivo de negativa") },
minLines = 2,
modifier = Modifier.fillMaxWidth(),
)
OutlinedButton(
onClick = { model.recordCompanyOutcome("REFUSED", refusalReason) },
enabled = !model.busy && inspectorSigned && refusalReason.trim().length >= 10,
modifier = Modifier.fillMaxWidth(),
) { Text("Registrar negativa a firmar") }
}
if (inspectorSigned && companyResolved) {
Button(
onClick = { model.closeSelectedAct() },
enabled = !model.busy,
modifier = Modifier.fillMaxWidth(),
) {
Icon(Icons.Filled.CheckCircle, null)
Spacer(Modifier.width(6.dp))
Text("Finalizar Acta")
}
} else {
Text(
"La inspección no puede cerrarse hasta resolver la firma, negativa o ausencia de esta Acta.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
"SEALED" -> {
ElevatedCard(Modifier.fillMaxWidth()) {
Row(Modifier.padding(18.dp), horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.Top) {
Icon(Icons.Filled.CheckCircle, null, tint = MaterialTheme.colorScheme.primary)
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(5.dp)) {
Text("Acta finalizada e inmutable", fontWeight = FontWeight.Bold)
Text("Al finalizar se genera el PDF del Acta y el archivo INF editable para el Inspector.")
closure?.closure?.finalSha256?.let { Text("SHA-256 final · $it", style = MaterialTheme.typography.bodySmall) }
}
}
}
}
"CANCELLED" -> {
Surface(Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.surfaceVariant) {
Text("Esta Acta fue cancelada y permanece como antecedente.", Modifier.padding(14.dp))
}
}
}
}
if (visit.status == "IN_PROGRESS" && model.acts.isNotEmpty()) {
val activeActs = model.acts.filter { it.status != "CANCELLED" }
val pendingActs = activeActs.filter { it.status != "SEALED" }
ElevatedCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(9.dp)) {
Text("Finalizar inspección", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
if (pendingActs.isEmpty()) {
SuccessLine("Todas las Actas están finalizadas")
} else {
Text(
"Actas pendientes de firma o finalización: ${pendingActs.joinToString { it.code }}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
Button(
onClick = { model.closeInspection() },
enabled = !model.busy && activeActs.isNotEmpty() && pendingActs.isEmpty(),
modifier = Modifier.fillMaxWidth(),
) { Text("Cerrar inspección y salir") }
}
}
}
Spacer(Modifier.height(32.dp))
}
}
@Composable
private fun SuccessLine(text: String) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(7.dp)) {
Icon(Icons.Filled.CheckCircle, null, tint = MaterialTheme.colorScheme.primary)
Text(text, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.SemiBold)
}
}
private fun documentTypeLabel(kind: String): String = when (kind) {
"DNI" -> "DNI"
"CUIL" -> "CUIL"
"PASSPORT" -> "Pasaporte"
"OTHER" -> "Otro"
else -> "Otro"
}
private fun modernActStatusLabel(status: String): String = when (status) {
"DRAFT" -> "Borrador"
"LOCKED" -> "Pendiente de firma"
"SEALED" -> "Finalizada"
"CANCELLED" -> "Cancelada"
else -> "Estado no reconocido"
}
private fun hasModernActLocation(context: Context): Boolean =
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
private suspend fun currentModernActSignatureGeo(context: Context): ModernActSignatureGeo = suspendCancellableCoroutine { continuation ->
if (!hasModernActLocation(context)) {
continuation.resumeWithException(SecurityException("Ubicación no autorizada"))
return@suspendCancellableCoroutine
}
val source = CancellationTokenSource()
try {
LocationServices.getFusedLocationProviderClient(context)
.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token)
.addOnSuccessListener { location ->
if (!continuation.isActive) return@addOnSuccessListener
if (location == null) continuation.resumeWithException(IllegalStateException("Ubicación no disponible"))
else continuation.resume(ModernActSignatureGeo(location.latitude, location.longitude, location.accuracy.toDouble()))
}
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
} catch (error: SecurityException) {
if (continuation.isActive) continuation.resumeWithException(error)
}
continuation.invokeOnCancellation { source.cancel() }
}
File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

@@ -1,26 +0,0 @@
package com.korexlabs.dhinspeccion
import java.io.File
import org.junit.Assert.assertTrue
import org.junit.Test
class DynamicFieldFlowContractTest {
private val source = File("src/main/java/com/korexlabs/dhinspeccion/ui/DynamicVisitRoot.kt").readText()
@Test
fun fastSubinstallationFlowKeepsParentClassificationAndCaptureSteps() {
assertTrue(source.contains("Elegí la Instalación padre"))
assertTrue(source.contains("Clasificación técnica *"))
assertTrue(source.contains("Guardar y tomar foto"))
assertTrue(source.contains("model.loadFieldTypes(item.id)"))
assertTrue(source.contains("parentId = item.id"))
}
@Test
fun fieldCaptureStillRequiresGpsAndPhotoBeforeFindings() {
assertTrue(source.contains("La foto sigue siendo obligatoria antes de crear Hallazgos"))
assertTrue(source.contains("creationGpsCaptured"))
assertTrue(source.contains("fieldPhotoCount == 0"))
assertTrue(source.contains("model.uploadFieldPhoto"))
}
}
@@ -1,68 +0,0 @@
package com.korexlabs.dhinspeccion
import java.io.File
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class FinalFieldFlowContractTest {
private val home = File("src/main/java/com/korexlabs/dhinspeccion/ui/MobileHomeScreen.kt").readText()
private val visit = File("src/main/java/com/korexlabs/dhinspeccion/ui/ModernVisitRoot.kt").readText()
private val acts = File("src/main/java/com/korexlabs/dhinspeccion/ui/ModernMobileActsScreen.kt").readText()
private val findings = File("src/main/java/com/korexlabs/dhinspeccion/ui/FieldFindingScreen.kt").readText()
private val viewModel = File("src/main/java/com/korexlabs/dhinspeccion/MainViewModel.kt").readText()
@Test
fun inspectionIsPlannedInOfficeAndOnlyStartedOnTablet() {
assertTrue(home.contains("Trabajo planificado desde oficina"))
assertFalse(home.contains("MobileInspectionOpenRepository"))
assertFalse(home.contains("Abrir inspección ahora"))
assertTrue(home.contains("fieldVisits = model.visits.filter { it.status != \"DRAFT\" }"))
assertTrue(home.contains("\"DRAFT\" -> \"Borrador\""))
assertTrue(visit.contains("Iniciar inspección"))
}
@Test
fun findingsAreScopedToInstallationsAndSubinstallations() {
assertTrue(visit.contains("setOf(\"instalacion\", \"subinstalacion\")"))
assertTrue(visit.contains("Buscar existente o cargar Instalación / Subinstalación"))
assertTrue(visit.contains("Guardar y tomar foto"))
assertTrue(visit.contains("La foto es obligatoria antes de registrar Hallazgos"))
}
@Test
fun fieldCreationKeepsDashboardIdentityAndTechnicalRequirements() {
assertTrue(visit.contains("Nombre técnico / identificación *"))
assertTrue(visit.contains("El Código DH de campo se genera automáticamente"))
assertTrue(visit.contains("Clasificación técnica"))
assertTrue(visit.contains("description = description"))
assertTrue(visit.contains("discoveryNotes = discoveryNotes"))
assertTrue(viewModel.contains("description = description?.trim()"))
assertTrue(viewModel.contains("discoveryNotes = discoveryNotes?.trim()"))
}
@Test
fun closingActLeavesItPendingForSignatureAndResolvesAbsenceCorrectly() {
assertTrue(acts.contains("Cerrar Acta y dejar pendiente de firma"))
assertTrue(acts.contains("Acta pendiente de firma"))
assertTrue(viewModel.contains("Acta cerrada y pendiente de firma"))
assertTrue(acts.contains("Registrar ausencia para la firma"))
assertTrue(acts.contains("recordCompanyOutcome(\"ABSENT\""))
assertTrue(viewModel.contains("status !in setOf(\"REFUSED\", \"ABSENT\")"))
assertTrue(acts.contains("Finalizar Acta"))
assertTrue(acts.contains("Acta finalizada e inmutable"))
}
@Test
fun activeFieldScreensDoNotExposeKnownEnglishLabels() {
val activeUi = home + visit + acts + findings
assertFalse(activeUi.contains("Text(\"Email\")"))
assertFalse(activeUi.contains("El checklist requiere actualización"))
assertFalse(activeUi.contains("Acta BLOQUEADA"))
assertFalse(activeUi.contains("SELLAR Acta"))
assertFalse(activeUi.contains("Acta SELLADA"))
assertTrue(acts.contains("\"PASSPORT\" -> \"Pasaporte\""))
assertTrue(acts.contains("\"OTHER\" -> \"Otro\""))
assertTrue(findings.contains("findingStatusLabel(finding.status)"))
}
}
@@ -8,8 +8,8 @@ class ReleaseMetadataTest {
@Test
fun debugBuildKeepsSeparateApplicationIdentity() {
assertEquals("com.korexlabs.dhinspeccion.debug", BuildConfig.APPLICATION_ID)
assertEquals(28, BuildConfig.VERSION_CODE)
assertEquals("0.19.0-debug", BuildConfig.VERSION_NAME)
assertEquals(24, BuildConfig.VERSION_CODE)
assertEquals("0.15.2-debug", BuildConfig.VERSION_NAME)
}
@Test
@@ -36,20 +36,6 @@ export interface FieldBriefingAct {
findings: FieldBriefingFinding[];
}
function toIsoDate(value: unknown): string {
const parsed = value instanceof Date
? value
: value == null
? new Date()
: new Date(String(value));
if (Number.isNaN(parsed.getTime())) {
throw new Error('La fecha planificada de la inspección no es válida');
}
return parsed.toISOString().slice(0, 10);
}
@Injectable()
export class FieldBriefingService {
constructor(private readonly dataSource: DataSource) {}
@@ -81,9 +67,7 @@ export class FieldBriefingService {
});
}
// PostgreSQL/pg entrega los timestamptz como Date. String(date).slice(0, 10)
// produce textos como "Thu Sep 17", que PostgreSQL rechaza al castear a date.
const plannedOn = toIsoDate(visit.plannedStartAt);
const plannedOn = String(visit.plannedStartAt ?? new Date().toISOString()).slice(0, 10);
const rows = (await this.dataSource.query(`
WITH prior_acts AS (
@@ -54,10 +54,6 @@ export class CreateAssetDto {
@IsUUID('4')
operatorCompanyId?: string | null;
@IsOptional()
@IsUUID('4')
concessionTypeId?: string | null;
@IsOptional()
@Transform(({ value }) =>
typeof value === 'string' && value.trim() ? value.trim() : null,
@@ -11,7 +11,6 @@ import {
// Invariante de dominio: Empresa es un maestro independiente. La jerarquía física
// se expresa sólo como Departamento → Área → Yacimiento → Instalación → Subinstalación.
// La Empresa y el Tipo de concesión se relacionan directamente con el Yacimiento.
export const INVENTORY_STRUCTURE_KINDS = [
'EMPRESA',
'DEPARTAMENTO',
@@ -53,14 +52,6 @@ export class CreateInventoryStructureDto {
@IsUUID('4')
familyId?: string | null;
@IsOptional()
@IsUUID('4')
operatorCompanyId?: string | null;
@IsOptional()
@IsUUID('4')
concessionTypeId?: string | null;
@IsOptional()
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null)
@IsString()
@@ -48,14 +48,14 @@ export class UpdateAssetDto {
@IsUUID('4')
operationalAreaId?: string | null;
/**
* Snapshot histórico/de compatibilidad. La Operadora vigente pertenece a la
* relación temporal Área↔Empresa y no puede editarse como propiedad física.
*/
@IsOptional()
@IsEmpty({ message: 'La Empresa relacionada se administra en el Yacimiento, no desde la edición genérica del Inventario' })
@IsEmpty({ message: 'La Operadora se administra en la relación temporal del Área, no en el Inventario' })
operatorCompanyId?: string | null;
@IsOptional()
@IsEmpty({ message: 'El Tipo de concesión se administra en el Yacimiento, no desde la edición genérica del Inventario' })
concessionTypeId?: string | null;
@IsOptional()
@Transform(({ value }) =>
typeof value === 'string' && value.trim() ? value.trim() : null,
@@ -20,7 +20,6 @@ import type { CreateInventoryStructureDto, InventoryStructureKind } from './dto/
import { AssetHistoryService } from './asset-history.service';
type StructureTypeRow = { id: string; code: string; name: string };
type SimpleOptionRow = { id: string; code: string; name: string };
type FamilyParent = { id: string; code: string; name: string };
type FamilyRow = {
id: string;
@@ -38,17 +37,9 @@ type ParentRow = {
name: string;
typeCode: string;
inventoryFamilyId: string | null;
operationalAreaId: string | null;
operatorCompanyId: string | null;
};
type IdRow = { id: string };
type YacimientoContext = {
companyId: string;
concessionTypeId: string;
concessionName: string;
};
const TYPE_CODE_BY_KIND: Record<Exclude<InventoryStructureKind, 'EMPRESA'>, string> = {
DEPARTAMENTO: 'departamento',
AREA: 'area',
@@ -123,21 +114,6 @@ export class InventoryStructureService {
ORDER BY family.level,family.name,family.code
`)) as FamilyRow[];
const companies = (await this.dataSource.query(`
SELECT asset.id,asset.code,COALESCE(profile.legal_name,asset.name) AS name
FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id AND type.operational_role='COMPANY' AND type.is_active=true
LEFT JOIN organization_profiles profile ON profile.asset_id=asset.id
WHERE asset.information_status<>'INACTIVE'
ORDER BY COALESCE(profile.legal_name,asset.name),asset.code
`)) as SimpleOptionRow[];
const concessionTypes = (await this.dataSource.query(`
SELECT id,code,name
FROM concession_types
WHERE is_active=true
ORDER BY CASE lower(name) WHEN 'explotación' THEN 1 WHEN 'exploración' THEN 2 ELSE 9 END,name,code
`)) as SimpleOptionRow[];
return {
independentMasters: [
{ kind: 'EMPRESA', label: 'Empresa', type: company, parentKind: null, requiresFamily: false },
@@ -149,8 +125,6 @@ export class InventoryStructureService {
{ kind: 'INSTALACION', label: 'Instalación', type: instalacion, parentKind: 'YACIMIENTO', requiresFamily: true },
{ kind: 'SUBINSTALACION', label: 'Subinstalación', type: subinstalacion, parentKind: 'INSTALACION', requiresFamily: true },
],
companies,
concessionTypes,
installationFamilies: families.filter((item) => item.level==='INSTALLATION'),
subinstallationFamilies: families.filter((item) => item.level==='SUBINSTALLATION'),
};
@@ -205,43 +179,25 @@ export class InventoryStructureService {
const type = await this.requireStructureType(manager, dto.kind);
const parent = await this.requireParent(manager, dto.kind, dto.parentId ?? null);
const family = await this.requireFamily(manager, dto.kind, dto.familyId ?? null, parent);
const yacimientoContext = await this.requireYacimientoContext(manager, dto);
const generatedCode = dto.code?.trim().toUpperCase() || this.generatedCode(dto.kind, dto.name);
const operationalAreaId = parent && ['YACIMIENTO','INSTALACION','SUBINSTALACION'].includes(dto.kind)
? await this.resolveAreaId(manager, parent)
: null;
const operatorCompanyId = dto.kind === 'YACIMIENTO'
? yacimientoContext?.companyId ?? null
: dto.kind === 'INSTALACION' || dto.kind === 'SUBINSTALACION'
? parent?.operatorCompanyId ?? null
: null;
const concessionTypeId = dto.kind === 'YACIMIENTO'
? yacimientoContext?.concessionTypeId ?? null
: null;
if ((dto.kind === 'INSTALACION' || dto.kind === 'SUBINSTALACION') && !operatorCompanyId) {
throw new ConflictException({
code: 'INVENTORY_YACIMIENTO_COMPANY_MISSING',
message: 'El Yacimiento de origen no tiene una Empresa relacionada válida',
});
}
const inserted = (await manager.query(`
INSERT INTO assets (
asset_type_id,parent_id,operational_area_id,operator_company_id,concession_type_id,inventory_family_id,
asset_type_id,parent_id,operational_area_id,operator_company_id,inventory_family_id,
code,name,common_name,description,information_status,operational_status,
data_origin,source_name,source_reference,source_notes,created_by,updated_by,provenance_updated_by
) VALUES (
$1::uuid,$2::uuid,$3::uuid,$4::uuid,$5::uuid,$6::uuid,
$7::varchar,$8::varchar,$9::varchar,$10::text,$11::asset_information_status,$12::asset_operational_status,
$13::varchar,$14::varchar,$15::varchar,$16::text,$17::uuid,$17::uuid,$17::uuid
$1::uuid,$2::uuid,$3::uuid,NULL,$4::uuid,
$5::varchar,$6::varchar,$7::varchar,$8::text,$9::asset_information_status,$10::asset_operational_status,
$11::varchar,$12::varchar,$13::varchar,$14::text,$15::uuid,$15::uuid,$15::uuid
) RETURNING id
`, [
type.id,
parent?.id ?? null,
operationalAreaId,
operatorCompanyId,
concessionTypeId,
family?.id ?? null,
generatedCode,
dto.name,
@@ -250,13 +206,9 @@ export class InventoryStructureService {
AssetInformationStatus.DRAFT,
AssetOperationalStatus.UNKNOWN,
AssetDataOrigin.MANUAL,
dto.kind === 'EMPRESA' ? 'Maestro manual de Empresas' : 'Estructura manual de Inventario',
dto.kind === 'EMPRESA' ? 'Maestro manual de Empresas F6' : 'Estructura manual de Inventario F6',
dto.kind === 'EMPRESA' ? 'inventory-master:empresa' : `inventory-structure:${dto.kind.toLowerCase()}`,
family
? `Clasificación técnica: ${family.code} · ${family.name}`
: yacimientoContext
? `Tipo de concesión: ${yacimientoContext.concessionName}`
: null,
family ? `Clasificación técnica: ${family.code} · ${family.name}` : null,
principal.userId,
])) as Array<{ id: string }>;
const id = inserted[0]?.id;
@@ -269,15 +221,6 @@ export class InventoryStructureService {
ON CONFLICT (asset_id) DO UPDATE SET legal_name=EXCLUDED.legal_name,updated_by=EXCLUDED.updated_by,updated_at=CURRENT_TIMESTAMP
`,[id,dto.name,principal.userId]);
}
if (dto.kind === 'YACIMIENTO' && operationalAreaId && operatorCompanyId && concessionTypeId) {
await this.ensureCompatibilityProjection(
manager,
operationalAreaId,
operatorCompanyId,
concessionTypeId,
yacimientoContext?.concessionName ?? 'Concesión',
);
}
const versionNumber = await this.history.capture(
manager,id,AssetVersionChangeType.CREATED,principal,request,
@@ -286,14 +229,10 @@ export class InventoryStructureService {
INSERT INTO asset_context_history (
asset_id,parent_id,operational_area_id,operator_company_id,valid_from,
change_reason,asset_version_number,source,request_id,created_by
) VALUES ($1,$2,$3,$4,CURRENT_TIMESTAMP,$5,$6,'WEB',$7,$8)
) VALUES ($1,$2,$3,NULL,CURRENT_TIMESTAMP,$4,$5,'WEB',$6,$7)
`, [
id,parent?.id ?? null,operationalAreaId,operatorCompanyId,
dto.kind === 'YACIMIENTO'
? 'Alta manual de Yacimiento con Área, Empresa y Tipo de concesión'
: dto.kind === 'EMPRESA'
? 'Alta manual de Empresa independiente'
: 'Alta manual de estructura de Inventario',
id,parent?.id ?? null,operationalAreaId,
dto.kind === 'EMPRESA' ? 'Alta manual de Empresa independiente F6' : 'Alta manual de estructura de Inventario F6',
versionNumber,request.requestId,principal.userId,
]);
const created = await this.loadView(manager, id);
@@ -305,8 +244,7 @@ export class InventoryStructureService {
metadata: {
versionNumber,inventoryStructureKind: dto.kind,
inventoryFamilyId: family?.id ?? null,inventoryFamilyCode: family?.code ?? null,
operatorCompanyId,
concessionTypeId,
operatorOwnership: false,
},
}, manager);
return created;
@@ -352,9 +290,7 @@ export class InventoryStructureService {
});
const rows = (await manager.query(`
SELECT asset.id,asset.code,asset.name,type.code AS "typeCode",
asset.inventory_family_id AS "inventoryFamilyId",
asset.operational_area_id AS "operationalAreaId",
asset.operator_company_id AS "operatorCompanyId"
asset.inventory_family_id AS "inventoryFamilyId"
FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id
WHERE asset.id=$1::uuid AND asset.information_status<>'INACTIVE'
@@ -369,65 +305,8 @@ export class InventoryStructureService {
return parent;
}
private async requireYacimientoContext(
manager: EntityManager,
dto: CreateInventoryStructureDto,
): Promise<YacimientoContext | null> {
if (dto.kind !== 'YACIMIENTO') {
if (dto.operatorCompanyId || dto.concessionTypeId) {
throw new BadRequestException({
code: 'INVENTORY_YACIMIENTO_CONTEXT_NOT_ALLOWED',
message: 'Empresa relacionada y Tipo de concesión sólo corresponden al Yacimiento',
});
}
return null;
}
if (!dto.operatorCompanyId) {
throw new BadRequestException({
code: 'INVENTORY_YACIMIENTO_COMPANY_REQUIRED',
message: 'Seleccioná la Empresa relacionada del Yacimiento',
});
}
if (!dto.concessionTypeId) {
throw new BadRequestException({
code: 'INVENTORY_YACIMIENTO_CONCESSION_REQUIRED',
message: 'Seleccioná el Tipo de concesión del Yacimiento',
});
}
const companyRows = (await manager.query(`
SELECT asset.id
FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id
WHERE asset.id=$1::uuid
AND type.operational_role='COMPANY'
AND type.is_active=true
AND asset.information_status<>'INACTIVE'
FOR KEY SHARE
`,[dto.operatorCompanyId])) as IdRow[];
if (!companyRows[0]) throw new BadRequestException({
code: 'INVENTORY_YACIMIENTO_COMPANY_INVALID',
message: 'La Empresa relacionada seleccionada no es válida',
});
const concessionRows = (await manager.query(`
SELECT id,name
FROM concession_types
WHERE id=$1::uuid AND is_active=true
FOR KEY SHARE
`,[dto.concessionTypeId])) as Array<{id:string;name:string}>;
if (!concessionRows[0]) throw new BadRequestException({
code: 'INVENTORY_YACIMIENTO_CONCESSION_INVALID',
message: 'El Tipo de concesión seleccionado no es válido',
});
return {
companyId: dto.operatorCompanyId,
concessionTypeId: dto.concessionTypeId,
concessionName: concessionRows[0].name,
};
}
private async resolveAreaId(manager: EntityManager,parent: ParentRow):Promise<string> {
if (parent.typeCode.toLowerCase()==='area') return parent.id;
if (parent.operationalAreaId) return parent.operationalAreaId;
const rows = (await manager.query(`
WITH RECURSIVE lineage AS (
SELECT asset.id,asset.parent_id,asset.asset_type_id FROM assets asset WHERE asset.id=$1::uuid
@@ -502,40 +381,6 @@ export class InventoryStructureService {
return family;
}
private async ensureCompatibilityProjection(
manager: EntityManager,
areaId: string,
companyId: string,
concessionTypeId: string,
concessionName: string,
): Promise<void> {
const companyProjection = await manager.query(`
SELECT id FROM area_company_relations
WHERE area_id=$1::uuid AND company_id=$2::uuid AND relation_role='OPERATOR' AND valid_to IS NULL
LIMIT 1
`,[areaId,companyId]);
if (!companyProjection[0]) {
await manager.query(`
INSERT INTO area_company_relations(area_id,company_id,relation_role,valid_from,start_reason)
VALUES($1::uuid,$2::uuid,'OPERATOR',CURRENT_TIMESTAMP,'Proyección derivada de Yacimiento')
`,[areaId,companyId]);
}
const rightType = concessionName.toLocaleLowerCase('es-AR').includes('explor')
? 'EXPLORATION_PERMIT'
: 'EXPLOITATION_CONCESSION';
const rightProjection = await manager.query(`
SELECT id FROM area_legal_rights
WHERE area_id=$1::uuid AND right_type=$2::area_legal_right_type AND status='ACTIVE'
LIMIT 1
`,[areaId,rightType]);
if (!rightProjection[0]) {
await manager.query(`
INSERT INTO area_legal_rights(area_id,right_type,name,status,notes)
VALUES($1::uuid,$2::area_legal_right_type,$3,'ACTIVE',$4)
`,[areaId,rightType,`${concessionName} · proyección de Yacimiento`,`Tipo de concesión canónico: ${concessionTypeId}`]);
}
}
private generatedCode(kind: InventoryStructureKind, name: string): string {
const prefix = kind === 'EMPRESA' ? 'EMP'
: kind === 'DEPARTAMENTO' ? 'DEP'
@@ -555,8 +400,6 @@ export class InventoryStructureService {
JSONB_BUILD_OBJECT('id',type.id,'code',type.code,'name',type.name) AS type,
CASE WHEN parent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',parent.id,'code',parent.code,'name',parent.name) END AS parent,
CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',area.id,'code',area.code,'name',area.name) END AS "operationalArea",
CASE WHEN company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',company.id,'code',company.code,'name',COALESCE(profile.legal_name,company.name)) END AS "operatorCompany",
CASE WHEN concession.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',concession.id,'code',concession.code,'name',concession.name) END AS "concessionType",
CASE WHEN family.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id',family.id,'code',family.code,'name',family.name,'level',family.level,
'informationLabels',family.information_labels
@@ -566,9 +409,6 @@ export class InventoryStructureService {
JOIN asset_types type ON type.id=asset.asset_type_id
LEFT JOIN assets parent ON parent.id=asset.parent_id
LEFT JOIN assets area ON area.id=asset.operational_area_id
LEFT JOIN assets company ON company.id=asset.operator_company_id
LEFT JOIN organization_profiles profile ON profile.asset_id=company.id
LEFT JOIN concession_types concession ON concession.id=asset.concession_type_id
LEFT JOIN inventory_families family ON family.id=asset.inventory_family_id
WHERE asset.id=$1::uuid
`, [id]);
@@ -33,7 +33,6 @@ export enum AssetDataOrigin {
@Index('idx_assets_parent_id', ['parentId'])
@Index('idx_assets_operational_area_id', ['operationalAreaId'])
@Index('idx_assets_operator_company_id', ['operatorCompanyId'])
@Index('idx_assets_concession_type_id', ['concessionTypeId'])
@Index('idx_assets_inventory_family_id', ['inventoryFamilyId'])
@Index('idx_assets_information_status', ['informationStatus'])
@Index('idx_assets_operational_status', ['operationalStatus'])
@@ -55,9 +54,6 @@ export class Asset extends TimestampedEntity {
@Column({ name: 'operator_company_id', type: 'uuid', nullable: true })
operatorCompanyId!: string | null;
@Column({ name: 'concession_type_id', type: 'uuid', nullable: true })
concessionTypeId!: string | null;
@Column({ name: 'inventory_family_id', type: 'uuid', nullable: true })
inventoryFamilyId!: string | null;
@@ -1,39 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class F62FreezeInspectionContext1790098200000 implements MigrationInterface {
name = 'F62FreezeInspectionContext1790098200000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE OR REPLACE FUNCTION prevent_inspection_context_mutation()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF NEW.operational_area_id IS DISTINCT FROM OLD.operational_area_id
OR NEW.scope_asset_id IS DISTINCT FROM OLD.scope_asset_id
OR NEW.operator_company_id IS DISTINCT FROM OLD.operator_company_id THEN
RAISE EXCEPTION USING
ERRCODE='23514',
MESSAGE='El Área, Yacimiento y Operadora de una Inspección quedan fijos desde su creación';
END IF;
RETURN NEW;
END $$
`);
await queryRunner.query(`
DROP TRIGGER IF EXISTS trg_inspection_context_immutable ON inspection_visits
`);
await queryRunner.query(`
CREATE TRIGGER trg_inspection_context_immutable
BEFORE UPDATE OF operational_area_id, scope_asset_id, operator_company_id
ON inspection_visits
FOR EACH ROW EXECUTE FUNCTION prevent_inspection_context_mutation()
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DROP TRIGGER IF EXISTS trg_inspection_context_immutable ON inspection_visits
`);
await queryRunner.query('DROP FUNCTION IF EXISTS prevent_inspection_context_mutation()');
}
}
@@ -1,57 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class F63MobileFieldCommonAttributes1790099100000 implements MigrationInterface {
name = 'F63MobileFieldCommonAttributes1790099100000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
WITH target_types AS (
SELECT id
FROM asset_types
WHERE lower(code) IN ('instalacion','subinstalacion')
), fields(code,name,sort_order) AS (
VALUES
('campo_marca','Marca',10),
('campo_modelo','Modelo',20),
('campo_capacidad','Capacidad',30),
('campo_numero_serie','Número de serie',40),
('campo_funcion','Función',50)
)
INSERT INTO asset_attribute_definitions (
asset_type_id, code, name, data_type, is_required, is_active, sort_order
)
SELECT target.id, fields.code, fields.name, 'TEXT'::asset_attribute_data_type,
false, true, fields.sort_order
FROM target_types target
CROSS JOIN fields
WHERE NOT EXISTS (
SELECT 1
FROM asset_attribute_definitions existing
WHERE existing.asset_type_id=target.id
AND lower(existing.code)=lower(fields.code)
)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DELETE FROM asset_attribute_values value
USING asset_attribute_definitions definition, asset_types type
WHERE value.definition_id=definition.id
AND definition.asset_type_id=type.id
AND lower(type.code) IN ('instalacion','subinstalacion')
AND definition.code IN (
'campo_marca','campo_modelo','campo_capacidad','campo_numero_serie','campo_funcion'
)
`);
await queryRunner.query(`
DELETE FROM asset_attribute_definitions definition
USING asset_types type
WHERE definition.asset_type_id=type.id
AND lower(type.code) IN ('instalacion','subinstalacion')
AND definition.code IN (
'campo_marca','campo_modelo','campo_capacidad','campo_numero_serie','campo_funcion'
)
`);
}
}
@@ -1,240 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
const TARGET_CODES = [
'departamento',
'area',
'yacimiento',
'instalacion',
'subinstalacion',
] as const;
const DELETE_ORDER = [
'subinstalacion',
'instalacion',
'yacimiento',
'area',
'departamento',
] as const;
type ProtectedSnapshot = {
users: string;
companies: string;
companyProfiles: string;
assetTypes: string;
assetAttributes: string;
inventoryFamilies: string;
familyAttributes: string;
findingCategories: string;
findingItems: string;
};
export class ResetOperationalHierarchyData1790099200000 implements MigrationInterface {
name = 'ResetOperationalHierarchyData1790099200000';
public async up(queryRunner: QueryRunner): Promise<void> {
// This is a one-time live-data cleanup, not a new canonical empty seed.
// Fresh CI/bootstrap databases intentionally have no admin account while
// replaying the historical migration chain, so they must retain the F6.1
// presentation seed used by hierarchy/planning contract tests.
const adminRows = (await queryRunner.query(`
SELECT id
FROM users
WHERE lower(btrim(username))='admin'
ORDER BY id
`)) as Array<{ id: string }>;
if (adminRows.length === 0) {
// eslint-disable-next-line no-console
console.log('[hierarchy-reset] skipped: no live admin account on migration replay');
return;
}
if (adminRows.length !== 1) {
throw new Error(
`Hierarchy reset aborted: expected exactly one live admin account, found ${adminRows.length}`,
);
}
const targetTypes = (await queryRunner.query(
`
SELECT lower(code) AS code
FROM asset_types
WHERE lower(code)=ANY($1::text[])
ORDER BY lower(code)
`,
[[...TARGET_CODES]],
)) as Array<{ code: string }>;
const found = new Set(targetTypes.map((row) => row.code));
const missing = TARGET_CODES.filter((code) => !found.has(code));
if (missing.length > 0) {
throw new Error(`Hierarchy reset aborted: missing asset types ${missing.join(', ')}`);
}
const before = await this.protectedSnapshot(queryRunner);
await queryRunner.query(
`
CREATE TEMP TABLE reset_target_assets ON COMMIT DROP AS
SELECT asset.id
FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id
WHERE lower(type.code)=ANY($1::text[])
`,
[[...TARGET_CODES]],
);
await queryRunner.query(`CREATE UNIQUE INDEX reset_target_assets_pk ON reset_target_assets(id)`);
const [targetCount] = (await queryRunner.query(
`SELECT COUNT(*)::integer AS total FROM reset_target_assets`,
)) as Array<{ total: number }>;
// An Inspection freezes Area/Yacimiento/Operadora from creation and several
// inspection tables hold RESTRICT references to the hierarchy. Keeping a
// transaction that points to deleted territory would be invalid, so the
// complete disposable inspection graph is cleared first.
await queryRunner.query('TRUNCATE TABLE inspection_visits CASCADE');
// Legacy administrative departments are also presentation/operational data.
// Current F6 Departments live in assets, but this prevents old rows from
// resurfacing through compatibility paths.
await queryRunner.query('TRUNCATE TABLE administrative_departments CASCADE');
// Legal-right participants depend on area_legal_rights rather than directly
// on assets. Remove them before the generic direct-FK cleanup below.
await queryRunner.query(`
DELETE FROM area_legal_right_organizations organization
USING area_legal_rights legal_right
WHERE organization.right_id=legal_right.id
AND legal_right.area_id IN (SELECT id FROM reset_target_assets)
`);
// Clean every table that directly references one of the hierarchy assets.
// This deliberately discovers the current schema instead of maintaining a
// fragile hand-written list as new dossier/history tables are added.
await queryRunner.query(`
DO $$
DECLARE dependency record;
BEGIN
FOR dependency IN
SELECT
namespace.nspname AS schema_name,
relation.relname AS table_name,
attribute.attname AS column_name
FROM pg_constraint constraint_row
JOIN pg_class relation ON relation.oid=constraint_row.conrelid
JOIN pg_namespace namespace ON namespace.oid=relation.relnamespace
JOIN LATERAL unnest(constraint_row.conkey) WITH ORDINALITY local_key(attnum,ordinality)
ON true
JOIN LATERAL unnest(constraint_row.confkey) WITH ORDINALITY referenced_key(attnum,ordinality)
ON referenced_key.ordinality=local_key.ordinality
JOIN pg_attribute attribute
ON attribute.attrelid=constraint_row.conrelid
AND attribute.attnum=local_key.attnum
JOIN pg_attribute referenced_attribute
ON referenced_attribute.attrelid=constraint_row.confrelid
AND referenced_attribute.attnum=referenced_key.attnum
WHERE constraint_row.contype='f'
AND constraint_row.confrelid='assets'::regclass
AND constraint_row.conrelid<>'assets'::regclass
AND array_length(constraint_row.conkey,1)=1
AND referenced_attribute.attname='id'
ORDER BY namespace.nspname,relation.relname,attribute.attname
LOOP
EXECUTE format(
'DELETE FROM %I.%I WHERE %I IN (SELECT id FROM reset_target_assets)',
dependency.schema_name,
dependency.table_name,
dependency.column_name
);
END LOOP;
END $$;
`);
// parent_id is RESTRICT, therefore physical hierarchy rows are deleted from
// the leaves upward. Company/Operator assets are intentionally not targets.
for (const code of DELETE_ORDER) {
await queryRunner.query(
`
DELETE FROM assets asset
USING asset_types type
WHERE asset.asset_type_id=type.id
AND lower(type.code)=$1
`,
[code],
);
}
const after = await this.protectedSnapshot(queryRunner);
for (const key of Object.keys(before) as Array<keyof ProtectedSnapshot>) {
if (before[key] !== after[key]) {
throw new Error(
`Hierarchy reset verification failed: protected ${key} changed (${before[key]} -> ${after[key]})`,
);
}
}
const [verification] = (await queryRunner.query(`
SELECT
(
SELECT COUNT(*)::integer
FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id
WHERE lower(type.code)=ANY($1::text[])
) AS hierarchy_assets,
(SELECT COUNT(*)::integer FROM administrative_departments) AS administrative_departments,
(SELECT COUNT(*)::integer FROM inspection_visits) AS inspection_visits
`, [[...TARGET_CODES]])) as Array<{
hierarchy_assets: number;
administrative_departments: number;
inspection_visits: number;
}>;
if (
!verification
|| Number(verification.hierarchy_assets) !== 0
|| Number(verification.administrative_departments) !== 0
|| Number(verification.inspection_visits) !== 0
) {
throw new Error(`Hierarchy reset verification failed: ${JSON.stringify(verification ?? {})}`);
}
// eslint-disable-next-line no-console
console.log(
`[hierarchy-reset] removed ${Number(targetCount?.total ?? 0)} Departamento/Área/Yacimiento/Instalación/Subinstalación assets; inspections and legacy departments cleared; users=${after.users}; companies=${after.companies} preserved`,
);
}
public async down(): Promise<void> {
throw new Error(
'ResetOperationalHierarchyData is intentionally destructive; restore the automatic deploy PRE database backup instead.',
);
}
private async protectedSnapshot(queryRunner: QueryRunner): Promise<ProtectedSnapshot> {
const [snapshot] = (await queryRunner.query(`
SELECT
(SELECT COUNT(*)::text FROM users) AS "users",
(
SELECT COUNT(*)::text
FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id
WHERE type.operational_role='COMPANY'
) AS "companies",
(
SELECT COUNT(*)::text
FROM organization_profiles profile
JOIN assets asset ON asset.id=profile.asset_id
JOIN asset_types type ON type.id=asset.asset_type_id
WHERE type.operational_role='COMPANY'
) AS "companyProfiles",
(SELECT COUNT(*)::text FROM asset_types) AS "assetTypes",
(SELECT COUNT(*)::text FROM asset_attribute_definitions) AS "assetAttributes",
(SELECT COUNT(*)::text FROM inventory_families) AS "inventoryFamilies",
(SELECT COUNT(*)::text FROM inventory_family_attribute_definitions) AS "familyAttributes",
(SELECT COUNT(*)::text FROM finding_categories) AS "findingCategories",
(SELECT COUNT(*)::text FROM finding_catalog_items) AS "findingItems"
`)) as ProtectedSnapshot[];
if (!snapshot) throw new Error('Hierarchy reset aborted: could not snapshot protected masters');
return snapshot;
}
}
@@ -1,173 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* One-time full live-data reset requested before loading the definitive source files.
*
* The database is left with only the single `admin` user and the minimum product
* scaffolding required to keep authentication/authorization and the core asset
* model functional. Every business, operational, imported, catalog, history,
* document, inspection and tenant/company row is removed.
*
* Fresh migration replays/CI do not have the live `admin` account at this point,
* so this migration intentionally no-ops there.
*/
export class FullLiveDataReset1790103000000 implements MigrationInterface {
name = 'FullLiveDataReset1790103000000';
public async up(queryRunner: QueryRunner): Promise<void> {
const adminRows = (await queryRunner.query(`
SELECT id, username
FROM users
WHERE lower(btrim(username))='admin'
ORDER BY id
`)) as Array<{ id: string; username: string }>;
if (adminRows.length === 0) {
// eslint-disable-next-line no-console
console.log('[full-live-reset] skipped: no live admin account on migration replay');
return;
}
if (adminRows.length !== 1) {
throw new Error(
`Full live reset aborted: expected exactly one username admin, found ${adminRows.length}`,
);
}
const adminId = adminRows[0].id;
// These are product/schema scaffolding, not customer/business data.
// Everything else in public is disposable live data for this reset.
const structuralTables = [
'roles',
'permissions',
'role_permissions',
'asset_types',
'asset_attribute_definitions',
'asset_type_parent_rules',
] as const;
const preservedTables = new Set<string>([
'typeorm_migrations',
'users',
'user_roles',
...structuralTables,
]);
const structuralCounts = new Map<string, string>();
for (const table of structuralTables) {
const safeTable = `"${table.replace(/"/g, '""')}"`;
const rows = (await queryRunner.query(
`SELECT count(*)::text AS total FROM ${safeTable}`,
)) as Array<{ total: string }>;
structuralCounts.set(table, rows[0]?.total ?? '0');
}
const adminRolesBefore = (await queryRunner.query(
`SELECT count(*)::text AS total FROM user_roles WHERE user_id=$1`,
[adminId],
)) as Array<{ total: string }>;
const adminRoleCount = adminRolesBefore[0]?.total ?? '0';
if (adminRoleCount === '0') {
throw new Error('Full live reset aborted: admin has no assigned role');
}
const tableRows = (await queryRunner.query(`
SELECT table_name
FROM information_schema.tables
WHERE table_schema='public'
AND table_type='BASE TABLE'
ORDER BY table_name
`)) as Array<{ table_name: string }>;
const disposableTables = tableRows
.map((row) => row.table_name)
.filter((table) => !preservedTables.has(table));
if (disposableTables.length > 0) {
const quoted = disposableTables
.map((table) => `"${table.replace(/"/g, '""')}"`)
.join(', ');
await queryRunner.query(`TRUNCATE TABLE ${quoted} RESTART IDENTITY CASCADE`);
}
// Keep only the owner's administrator account. user_roles for other users
// are removed through their FK cascade.
await queryRunner.query(`DELETE FROM users WHERE id<>$1`, [adminId]);
// Invalidate any previous login state and unlock the preserved account.
await queryRunner.query(
`
UPDATE users
SET failed_login_attempts=0,
locked_until=NULL,
last_login_at=NULL,
updated_at=CURRENT_TIMESTAMP
WHERE id=$1
`,
[adminId],
);
const finalUsers = (await queryRunner.query(`
SELECT
count(*)::text AS total,
count(*) FILTER (WHERE lower(btrim(username))='admin')::text AS admins
FROM users
`)) as Array<{ total: string; admins: string }>;
if (finalUsers[0]?.total !== '1' || finalUsers[0]?.admins !== '1') {
throw new Error('Full live reset verification failed: users table is not admin-only');
}
const finalAdminRoles = (await queryRunner.query(
`
SELECT
count(*) FILTER (WHERE user_id=$1)::text AS total,
count(*) FILTER (WHERE user_id<>$1)::text AS foreign_users
FROM user_roles
`,
[adminId],
)) as Array<{ total: string; foreign_users: string }>;
if (
finalAdminRoles[0]?.total !== adminRoleCount ||
finalAdminRoles[0]?.foreign_users !== '0'
) {
throw new Error('Full live reset verification failed: admin role assignments changed');
}
for (const table of structuralTables) {
const safeTable = `"${table.replace(/"/g, '""')}"`;
const rows = (await queryRunner.query(
`SELECT count(*)::text AS total FROM ${safeTable}`,
)) as Array<{ total: string }>;
const before = structuralCounts.get(table) ?? '0';
if (rows[0]?.total !== before) {
throw new Error(
`Full live reset verification failed: structural table ${table} changed (${before} -> ${rows[0]?.total ?? 'unknown'})`,
);
}
}
for (const table of disposableTables) {
const safeTable = `"${table.replace(/"/g, '""')}"`;
const rows = (await queryRunner.query(
`SELECT count(*)::text AS total FROM ${safeTable}`,
)) as Array<{ total: string }>;
if (rows[0]?.total !== '0') {
throw new Error(`Full live reset verification failed: ${table} is not empty`);
}
}
// eslint-disable-next-line no-console
console.log(
`[full-live-reset] kept admin=${adminRows[0].username} (${adminId}); preserved ${structuralTables.length} product tables; cleared ${disposableTables.length} data tables`,
);
}
public async down(): Promise<void> {
throw new Error(
'FullLiveDataReset is intentionally destructive; restore the automatic deploy PRE database backup instead.',
);
}
}
@@ -1,380 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import { AUTHORITATIVE_INVENTORY_MODEL as SOURCE } from '../../reference-data/authoritative-inventory-model';
type IdRow = { id: string };
const TERRITORY_DOC = 'DH-AUTH-TERRITORY-20260911';
const INVENTORY_DOC = 'DH-AUTH-INVENTORY-20260911';
const FINDING_CATEGORY_CODE = 'AUTHMODEL';
function sourceCode(prefix: string, id: number): string {
return `${prefix}-${String(id).padStart(4, '0')}`;
}
export class AuthoritativeInventoryRelationships1790106600000 implements MigrationInterface {
name = 'AuthoritativeInventoryRelationships1790106600000';
public async up(q: QueryRunner): Promise<void> {
this.assertSource();
const companyType = await this.type(q, `SELECT id FROM asset_types WHERE operational_role='COMPANY' AND is_active=true ORDER BY (lower(code)='empresa') DESC, created_at LIMIT 1`);
const departmentType = await this.type(q, `SELECT id FROM asset_types WHERE lower(code)='departamento' AND is_active=true LIMIT 1`);
const areaType = await this.type(q, `SELECT id FROM asset_types WHERE lower(code)='area' AND operational_role='AREA' AND is_active=true LIMIT 1`);
const fieldType = await this.type(q, `SELECT id FROM asset_types WHERE lower(code)='yacimiento' AND is_active=true LIMIT 1`);
const installationType = await this.type(q, `SELECT id FROM asset_types WHERE lower(code)='instalacion' AND is_active=true LIMIT 1`);
const subinstallationType = await this.type(q, `SELECT id FROM asset_types WHERE lower(code)='subinstalacion' AND is_active=true LIMIT 1`);
await q.query(`
CREATE TABLE IF NOT EXISTS concession_types (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
code varchar(80) NOT NULL UNIQUE,
name varchar(120) NOT NULL UNIQUE,
is_active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_concession_types_name CHECK (length(btrim(name)) >= 3)
)
`);
await q.query(`ALTER TABLE assets ADD COLUMN IF NOT EXISTS concession_type_id uuid`);
await q.query(`
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname='fk_assets_concession_type') THEN
ALTER TABLE assets ADD CONSTRAINT fk_assets_concession_type
FOREIGN KEY (concession_type_id) REFERENCES concession_types(id) ON DELETE RESTRICT;
END IF;
END $$
`);
await q.query(`CREATE INDEX IF NOT EXISTS idx_assets_concession_type_id ON assets(concession_type_id)`);
await q.query(`
DO $$ BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname='dhv2_app') THEN
GRANT SELECT ON concession_types TO dhv2_app;
END IF;
END $$
`);
await q.query(`DELETE FROM asset_type_parent_rules WHERE child_type_id IN ($1::uuid,$2::uuid,$3::uuid,$4::uuid)`, [areaType, fieldType, installationType, subinstallationType]);
await q.query(`
INSERT INTO asset_type_parent_rules(child_type_id,parent_type_id) VALUES
($1::uuid,$2::uuid),($3::uuid,$1::uuid),($4::uuid,$3::uuid),($5::uuid,$4::uuid)
ON CONFLICT DO NOTHING
`, [areaType, departmentType, fieldType, installationType, subinstallationType]);
await q.query(`UPDATE asset_types SET can_be_root=true,updated_at=CURRENT_TIMESTAMP WHERE id IN ($1::uuid,$2::uuid)`, [companyType, departmentType]);
await q.query(`UPDATE asset_types SET can_be_root=false,updated_at=CURRENT_TIMESTAMP WHERE id IN ($1::uuid,$2::uuid,$3::uuid,$4::uuid)`, [areaType, fieldType, installationType, subinstallationType]);
await q.query(`UPDATE asset_types SET description='Área territorial. Sólo posee Nombre y pertenece obligatoriamente a un Departamento.',updated_at=CURRENT_TIMESTAMP WHERE id=$1::uuid`, [areaType]);
await q.query(`UPDATE asset_types SET description='Yacimiento. Pertenece a un Área y define directamente Tipo de concesión y Empresa relacionada.',updated_at=CURRENT_TIMESTAMP WHERE id=$1::uuid`, [fieldType]);
await q.query(`UPDATE asset_types SET description='Instalación física. Pertenece obligatoriamente a un Yacimiento y utiliza una clasificación técnica.',updated_at=CURRENT_TIMESTAMP WHERE id=$1::uuid`, [installationType]);
await q.query(`UPDATE asset_types SET description='Subinstalación física. Pertenece obligatoriamente a una Instalación y utiliza una clasificación técnica compatible.',updated_at=CURRENT_TIMESTAMP WHERE id=$1::uuid`, [subinstallationType]);
await q.query(`DELETE FROM asset_attribute_definitions WHERE asset_type_id IN ($1::uuid,$2::uuid,$3::uuid,$4::uuid,$5::uuid,$6::uuid)`, [companyType, departmentType, areaType, fieldType, installationType, subinstallationType]);
await this.insertCommonAttributes(q, installationType, [
['campo_marca','Marca',10],
['tipo_instalacion','Tipo de instalación',20],
['campo_modelo','Modelo',30],
['campo_capacidad','Capacidad',40],
['campo_numero_serie','Número de serie',50],
['campo_funcion','Función',60],
]);
await this.insertCommonAttributes(q, subinstallationType, [
['campo_marca','Marca',10],
['campo_modelo','Modelo',20],
['campo_capacidad','Capacidad',30],
['campo_numero_serie','Número de serie',40],
['campo_funcion','Función',50],
]);
await q.query(`
DO $$ DECLARE trigger_name text; BEGIN
FOR trigger_name IN
SELECT trigger_row.tgname
FROM pg_trigger trigger_row
JOIN pg_proc function_row ON function_row.oid=trigger_row.tgfoid
WHERE trigger_row.tgrelid='assets'::regclass
AND NOT trigger_row.tgisinternal
AND function_row.proname='enforce_asset_operational_context'
LOOP
EXECUTE format('DROP TRIGGER %I ON assets',trigger_name);
END LOOP;
END $$
`);
await q.query(`DROP FUNCTION IF EXISTS enforce_asset_operational_context()`);
await q.query(`
CREATE OR REPLACE FUNCTION enforce_authoritative_inventory_relationships()
RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE
kind text;
asset_role asset_type_operational_role;
parent_kind text;
parent_area uuid;
parent_company uuid;
parent_family uuid;
company_role asset_type_operational_role;
family_level text;
BEGIN
SELECT lower(code),operational_role INTO kind,asset_role FROM asset_types WHERE id=NEW.asset_type_id;
IF kind IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='asset type does not exist'; END IF;
IF asset_role='COMPANY'::asset_type_operational_role THEN
IF NEW.parent_id IS NOT NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Empresa no admite padre'; END IF;
NEW.operational_area_id:=NULL; NEW.operator_company_id:=NULL; NEW.concession_type_id:=NULL;
NEW.inventory_family_id:=NULL; NEW.is_inventory_instance:=false;
RETURN NEW;
ELSIF kind='departamento' THEN
IF NEW.parent_id IS NOT NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Departamento no admite padre'; END IF;
NEW.operational_area_id:=NULL; NEW.operator_company_id:=NULL; NEW.concession_type_id:=NULL;
NEW.inventory_family_id:=NULL; NEW.is_inventory_instance:=false;
RETURN NEW;
END IF;
IF NEW.parent_id IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El nivel requiere un padre estructural'; END IF;
SELECT lower(parent_type.code), parent.operational_area_id, parent.operator_company_id, parent.inventory_family_id
INTO parent_kind,parent_area,parent_company,parent_family
FROM assets parent JOIN asset_types parent_type ON parent_type.id=parent.asset_type_id
WHERE parent.id=NEW.parent_id AND parent.information_status<>'INACTIVE';
IF parent_kind IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El padre estructural no existe o está inactivo'; END IF;
IF kind='area' THEN
IF parent_kind<>'departamento' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Área debe pertenecer a un Departamento'; END IF;
NEW.operational_area_id:=NULL; NEW.operator_company_id:=NULL; NEW.concession_type_id:=NULL;
NEW.inventory_family_id:=NULL; NEW.is_inventory_instance:=false;
ELSIF kind='yacimiento' THEN
IF parent_kind<>'area' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Yacimiento debe pertenecer a un Área'; END IF;
IF NEW.operator_company_id IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Yacimiento requiere Empresa relacionada'; END IF;
SELECT type.operational_role INTO company_role FROM assets company JOIN asset_types type ON type.id=company.asset_type_id
WHERE company.id=NEW.operator_company_id AND company.information_status<>'INACTIVE' AND type.is_active=true;
IF company_role IS DISTINCT FROM 'COMPANY'::asset_type_operational_role THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='La Empresa relacionada del Yacimiento no es válida'; END IF;
IF NEW.concession_type_id IS NULL OR NOT EXISTS(SELECT 1 FROM concession_types c WHERE c.id=NEW.concession_type_id AND c.is_active=true) THEN
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Yacimiento requiere un Tipo de concesión válido';
END IF;
IF NEW.operational_area_id IS NOT NULL AND NEW.operational_area_id<>NEW.parent_id THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El Área operativa del Yacimiento debe coincidir con su Área padre'; END IF;
NEW.operational_area_id:=NEW.parent_id; NEW.inventory_family_id:=NULL; NEW.is_inventory_instance:=false;
ELSIF kind='instalacion' THEN
IF parent_kind<>'yacimiento' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Instalación debe pertenecer a un Yacimiento'; END IF;
SELECT level::text INTO family_level FROM inventory_families WHERE id=NEW.inventory_family_id AND is_active=true;
IF family_level IS DISTINCT FROM 'INSTALLATION' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Instalación requiere un Tipo de instalación válido'; END IF;
NEW.operational_area_id:=parent_area; NEW.operator_company_id:=parent_company; NEW.concession_type_id:=NULL; NEW.is_inventory_instance:=true;
ELSIF kind='subinstalacion' THEN
IF parent_kind<>'instalacion' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Subinstalación debe pertenecer a una Instalación'; END IF;
SELECT level::text INTO family_level FROM inventory_families WHERE id=NEW.inventory_family_id AND is_active=true;
IF family_level IS DISTINCT FROM 'SUBINSTALLATION' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Subinstalación requiere un Tipo de subinstalación válido'; END IF;
IF NOT EXISTS(SELECT 1 FROM inventory_family_parent_rules rule WHERE rule.child_family_id=NEW.inventory_family_id AND rule.parent_family_id=parent_family) THEN
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El Tipo de subinstalación no es compatible con el Tipo de instalación padre';
END IF;
NEW.operational_area_id:=parent_area; NEW.operator_company_id:=parent_company; NEW.concession_type_id:=NULL; NEW.is_inventory_instance:=true;
END IF;
RETURN NEW;
END $$
`);
await q.query(`DROP TRIGGER IF EXISTS trg_assets_authoritative_inventory_relationships ON assets`);
await q.query(`
CREATE TRIGGER trg_assets_authoritative_inventory_relationships
BEFORE INSERT OR UPDATE OF asset_type_id,parent_id,operational_area_id,operator_company_id,inventory_family_id,concession_type_id
ON assets FOR EACH ROW EXECUTE FUNCTION enforce_authoritative_inventory_relationships()
`);
await q.query(`TRUNCATE TABLE assets CASCADE`);
await q.query(`TRUNCATE TABLE source_documents CASCADE`);
await q.query(`TRUNCATE TABLE inventory_families CASCADE`);
await q.query(`TRUNCATE TABLE finding_categories CASCADE`);
await q.query(`TRUNCATE TABLE concession_types CASCADE`);
const [territoryDoc] = (await q.query(`
INSERT INTO source_documents(document_type,document_number,title,issuer,external_reference,notes)
VALUES('SPREADSHEET',$1,$2,'Dirección de Hidrocarburos',$3,$4) RETURNING id
`, [TERRITORY_DOC,'modelo_yacimientos.sql','sha256:6a1d96bc8af7e755dd0e2fd9faf08335625b9c76865208c3b8413d868aa5eb1a','Modelo relacional exacto de Departamentos, Áreas, Yacimientos, Empresas y Tipo de concesión.'])) as IdRow[];
const [inventoryDoc] = (await q.query(`
INSERT INTO source_documents(document_type,document_number,title,issuer,external_reference,notes)
VALUES('SPREADSHEET',$1,$2,'Dirección de Hidrocarburos',$3,$4) RETURNING id
`, [INVENTORY_DOC,'modelo_relacional instalaciones y sub.sql','sha256:fb513f3909e78db298361a8a6998ca77026748f9b03cbab44c77b884ca862b49','Modelo relacional exacto de tipos de Instalación/Subinstalación y sus Hallazgos.'])) as IdRow[];
if(!territoryDoc?.id || !inventoryDoc?.id) throw new Error('Authoritative source documents could not be created');
const concessionIds=new Map<number,string>();
for(const row of SOURCE.concessionTypes){
const [created]=(await q.query(`INSERT INTO concession_types(code,name,is_active) VALUES($1,$2,true) RETURNING id`,[sourceCode('CONC',row.id),row.nombre])) as IdRow[];
if(!created?.id)throw new Error(`Could not create concession ${row.nombre}`); concessionIds.set(row.id,created.id);
}
await this.seedFamiliesAndFindings(q);
const companyIds=new Map<number,string>();
for(const row of SOURCE.companies){
const id=await this.asset(q,companyType,null,null,null,null,sourceCode('ORG',row.id),row.nombre,'modelo_yacimientos.sql',`empresas:${row.id}`);
companyIds.set(row.id,id);
await q.query(`INSERT INTO organization_profiles(asset_id,organization_kind,legal_name) VALUES($1::uuid,$2::organization_kind,$3)`,[id,row.nombre.toUpperCase().startsWith('UTE (')?'UTE':'COMPANY',row.nombre]);
await this.link(q,id,territoryDoc.id,`Empresa · source id ${row.id}`);
}
const departmentIds=new Map<number,string>();
for(const row of SOURCE.departments){
const id=await this.asset(q,departmentType,null,null,null,null,sourceCode('DEP',row.id),row.nombre,'modelo_yacimientos.sql',`departamentos:${row.id}`);
departmentIds.set(row.id,id); await this.link(q,id,territoryDoc.id,`Departamento · source id ${row.id}`);
}
const areaIds=new Map<number,string>();
for(const row of SOURCE.areas){
const parent=departmentIds.get(row.departamento_id); if(!parent)throw new Error(`Missing source Departamento ${row.departamento_id}`);
const id=await this.asset(q,areaType,parent,null,null,null,sourceCode('AREA',row.id),row.nombre,'modelo_yacimientos.sql',`areas:${row.id}`);
areaIds.set(row.id,id); await this.link(q,id,territoryDoc.id,`Área · source id ${row.id}`);
}
for(const row of SOURCE.fields){
const area=areaIds.get(row.area_id); const company=companyIds.get(row.empresa_id); const concession=concessionIds.get(row.tipo_concesion_id);
if(!area||!company||!concession)throw new Error(`Incomplete source relations for Yacimiento ${row.id}`);
const id=await this.asset(q,fieldType,area,area,company,concession,sourceCode('YAC',row.id),row.nombre,'modelo_yacimientos.sql',`yacimientos:${row.id}`);
await this.link(q,id,territoryDoc.id,`Yacimiento · source id ${row.id}`);
}
await q.query(`TRUNCATE TABLE area_company_relations CASCADE`);
await q.query(`TRUNCATE TABLE area_legal_rights CASCADE`);
const projectedPairs=new Set<string>();
const projectedRights=new Set<string>();
for(const row of SOURCE.fields){
const area=areaIds.get(row.area_id)!; const company=companyIds.get(row.empresa_id)!; const concession=concessionIds.get(row.tipo_concesion_id)!;
const pair=`${area}|${company}`;
if(!projectedPairs.has(pair)){
projectedPairs.add(pair);
await q.query(`INSERT INTO area_company_relations(area_id,company_id,relation_role,source_document_id,valid_from,start_reason)
VALUES($1::uuid,$2::uuid,'OPERATOR',$3::uuid,CURRENT_TIMESTAMP,$4)`,[area,company,territoryDoc.id,'Derivado automáticamente de Yacimientos del modelo autoritativo']);
}
const rightKey=`${area}|${concession}`;
if(!projectedRights.has(rightKey)){
projectedRights.add(rightKey);
const concessionRow=SOURCE.concessionTypes.find((item)=>item.id===row.tipo_concesion_id)!;
await q.query(`INSERT INTO area_legal_rights(area_id,right_type,name,status,source_document_id,notes)
VALUES($1::uuid,$2::area_legal_right_type,$3,'ACTIVE',$4::uuid,$5)`,[
area,concessionRow.nombre==='Exploración'?'EXPLORATION_PERMIT':'EXPLOITATION_CONCESSION',
`${concessionRow.nombre} · proyección del Yacimiento`,territoryDoc.id,'Compatibilidad derivada; el Tipo de concesión canónico está en Yacimiento.']);
}
}
await this.installFamilyFindingSync(q);
await this.verify(q);
}
public async down():Promise<void>{
throw new Error('Authoritative inventory model is a deliberate clean-load migration; restore the deploy PRE database backup.');
}
private assertSource():void{
if(SOURCE.departments.length!==7||SOURCE.companies.length!==13||SOURCE.concessionTypes.length!==2||SOURCE.areas.length!==64||SOURCE.fields.length!==230)
throw new Error('Authoritative territory source cardinality mismatch');
if(SOURCE.installationFamilies.length!==14||SOURCE.subinstallationFamilies.length!==109||SOURCE.findings.length!==181||SOURCE.installationFindings.length!==48||SOURCE.subinstallationFindings.length!==880)
throw new Error('Authoritative inventory source cardinality mismatch');
for(const area of SOURCE.areas) if(!SOURCE.departments.some((d)=>d.id===area.departamento_id)) throw new Error(`Area ${area.id} has no source Departamento`);
for(const field of SOURCE.fields){
if(!SOURCE.areas.some((a)=>a.id===field.area_id)||!SOURCE.companies.some((c)=>c.id===field.empresa_id)||!SOURCE.concessionTypes.some((c)=>c.id===field.tipo_concesion_id))
throw new Error(`Yacimiento ${field.id} has invalid source relationship`);
}
}
private async insertCommonAttributes(q:QueryRunner,typeId:string,rows:Array<[string,string,number]>):Promise<void>{
for(const [code,name,sortOrder] of rows) await q.query(`
INSERT INTO asset_attribute_definitions(asset_type_id,code,name,data_type,is_required,is_active,unit,options,sort_order)
VALUES($1::uuid,$2,$3,'TEXT'::asset_attribute_data_type,false,true,NULL,NULL,$4)
`,[typeId,code,name,sortOrder]);
}
private async seedFamiliesAndFindings(q:QueryRunner):Promise<void>{
const installationIds=new Map<number,string>();
for(const row of SOURCE.installationFamilies){
const [created]=(await q.query(`INSERT INTO inventory_families(code,name,level,information_labels,source_reference,is_active)
VALUES($1,$2,'INSTALLATION','[]'::jsonb,$3,true) RETURNING id`,[sourceCode('AUTH-I',row.id),row.nombre,`modelo_relacional:instalaciones:${row.id}`])) as IdRow[];
if(!created?.id)throw new Error(`Could not create Instalación family ${row.id}`); installationIds.set(row.id,created.id);
}
const subIds=new Map<number,string>();
for(const row of SOURCE.subinstallationFamilies){
const [created]=(await q.query(`INSERT INTO inventory_families(code,name,level,information_labels,source_reference,is_active)
VALUES($1,$2,'SUBINSTALLATION','[]'::jsonb,$3,true) RETURNING id`,[sourceCode('AUTH-S',row.id),row.nombre,`modelo_relacional:subinstalaciones:${row.id}`])) as IdRow[];
const parent=installationIds.get(row.instalacion_id); if(!created?.id||!parent)throw new Error(`Invalid Subinstalación family ${row.id}`);
subIds.set(row.id,created.id);
await q.query(`INSERT INTO inventory_family_parent_rules(child_family_id,parent_family_id) VALUES($1::uuid,$2::uuid)`,[created.id,parent]);
}
const [category]=(await q.query(`INSERT INTO finding_categories(code,name,sort_order,is_active) VALUES($1,$2,300,true) RETURNING id`,[FINDING_CATEGORY_CODE,'DH · Hallazgos del modelo autoritativo'])) as IdRow[];
if(!category?.id)throw new Error('Could not create authoritative finding category');
const findingIds=new Map<number,string>();
for(const row of SOURCE.findings){
const [created]=(await q.query(`INSERT INTO finding_catalog_items(category_id,code,source_number,title,import_note,revision,is_active)
VALUES($1::uuid,$2,$3,$4,$5,1,true) RETURNING id`,[category.id,sourceCode('AUTH-H',row.id),row.id,row.nombre,'modelo_relacional instalaciones y sub.sql'])) as IdRow[];
if(!created?.id)throw new Error(`Could not create Hallazgo ${row.id}`); findingIds.set(row.id,created.id);
await q.query(`INSERT INTO finding_catalog_item_versions(item_id,revision,snapshot,actor_username)
SELECT item.id,item.revision,jsonb_build_object('id',item.id,'categoryId',item.category_id,'code',item.code,'sourceNumber',item.source_number,'title',item.title,'revision',item.revision,'isActive',item.is_active),'migration:AUTHORITATIVE'
FROM finding_catalog_items item WHERE item.id=$1::uuid`,[created.id]);
}
for(const mapping of SOURCE.installationFindings){
const family=installationIds.get(mapping.instalacion_id); const finding=findingIds.get(mapping.hallazgo_id);
if(!family||!finding)throw new Error('Invalid installation finding mapping');
await q.query(`INSERT INTO finding_catalog_item_inventory_families(catalog_item_id,inventory_family_id) VALUES($1::uuid,$2::uuid)`,[finding,family]);
}
for(const mapping of SOURCE.subinstallationFindings){
const family=subIds.get(mapping.subinstalacion_id); const finding=findingIds.get(mapping.hallazgo_id);
if(!family||!finding)throw new Error('Invalid subinstallation finding mapping');
await q.query(`INSERT INTO finding_catalog_item_inventory_families(catalog_item_id,inventory_family_id) VALUES($1::uuid,$2::uuid)`,[finding,family]);
}
}
private async installFamilyFindingSync(q:QueryRunner):Promise<void>{
await q.query(`
CREATE OR REPLACE FUNCTION sync_asset_inventory_family_catalog()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
DELETE FROM finding_catalog_asset_overrides WHERE asset_id=NEW.id AND reason LIKE 'AUTHORITATIVE familia técnica:%';
IF NEW.inventory_family_id IS NOT NULL THEN
INSERT INTO finding_catalog_asset_overrides(asset_id,catalog_item_id,is_enabled,reason,created_by,updated_by)
SELECT NEW.id,m.catalog_item_id,true,'AUTHORITATIVE familia técnica: catálogo contextual automático',NEW.created_by,NEW.updated_by
FROM finding_catalog_item_inventory_families m WHERE m.inventory_family_id=NEW.inventory_family_id
ON CONFLICT(asset_id,catalog_item_id) DO UPDATE SET is_enabled=true,reason='AUTHORITATIVE familia técnica: catálogo contextual automático',updated_by=NEW.updated_by,updated_at=CURRENT_TIMESTAMP;
END IF;
RETURN NEW;
END $$
`);
await q.query(`
CREATE OR REPLACE FUNCTION sync_inventory_family_mapping_assets()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF TG_OP='DELETE' THEN
DELETE FROM finding_catalog_asset_overrides o USING assets a
WHERE o.asset_id=a.id AND a.inventory_family_id=OLD.inventory_family_id AND o.catalog_item_id=OLD.catalog_item_id AND o.reason LIKE 'AUTHORITATIVE familia técnica:%';
RETURN OLD;
END IF;
INSERT INTO finding_catalog_asset_overrides(asset_id,catalog_item_id,is_enabled,reason,created_by,updated_by)
SELECT a.id,NEW.catalog_item_id,true,'AUTHORITATIVE familia técnica: catálogo contextual automático',a.created_by,a.updated_by
FROM assets a WHERE a.inventory_family_id=NEW.inventory_family_id
ON CONFLICT(asset_id,catalog_item_id) DO UPDATE SET is_enabled=true,reason='AUTHORITATIVE familia técnica: catálogo contextual automático',updated_at=CURRENT_TIMESTAMP;
RETURN NEW;
END $$
`);
}
private async asset(q:QueryRunner,typeId:string,parentId:string|null,areaId:string|null,companyId:string|null,concessionId:string|null,
code:string,name:string,sourceName:string,sourceReference:string):Promise<string>{
const [row]=(await q.query(`INSERT INTO assets(asset_type_id,parent_id,operational_area_id,operator_company_id,concession_type_id,inventory_family_id,
code,name,information_status,operational_status,data_origin,source_name,source_reference,is_inventory_instance)
VALUES($1::uuid,$2::uuid,$3::uuid,$4::uuid,$5::uuid,NULL,$6,$7,'VALIDATED','UNKNOWN','PROVIDED_DOCUMENT',$8,$9,false) RETURNING id`,
[typeId,parentId,areaId,companyId,concessionId,code,name,sourceName,sourceReference])) as IdRow[];
if(!row?.id)throw new Error(`Could not seed ${code}`); return row.id;
}
private async link(q:QueryRunner,assetId:string,documentId:string,notes:string):Promise<void>{
await q.query(`INSERT INTO asset_source_documents(asset_id,document_id,relation_type,notes) VALUES($1::uuid,$2::uuid,'SOURCE',$3)`,[assetId,documentId,notes]);
}
private async type(q:QueryRunner,sql:string):Promise<string>{
const rows=(await q.query(sql)) as IdRow[]; if(!rows[0]?.id)throw new Error(`Missing canonical asset type: ${sql}`); return rows[0].id;
}
private async verify(q:QueryRunner):Promise<void>{
const [row]=await q.query(`SELECT
(SELECT COUNT(*) FROM assets a JOIN asset_types t ON t.id=a.asset_type_id WHERE t.operational_role='COMPANY')::integer companies,
(SELECT COUNT(*) FROM assets a JOIN asset_types t ON t.id=a.asset_type_id WHERE lower(t.code)='departamento')::integer departments,
(SELECT COUNT(*) FROM assets a JOIN asset_types t ON t.id=a.asset_type_id WHERE lower(t.code)='area')::integer areas,
(SELECT COUNT(*) FROM assets a JOIN asset_types t ON t.id=a.asset_type_id WHERE lower(t.code)='yacimiento')::integer fields,
(SELECT COUNT(*) FROM concession_types)::integer concessions,
(SELECT COUNT(*) FROM inventory_families WHERE level='INSTALLATION')::integer installation_families,
(SELECT COUNT(*) FROM inventory_families WHERE level='SUBINSTALLATION')::integer subinstallation_families,
(SELECT COUNT(*) FROM finding_catalog_items)::integer findings,
(SELECT COUNT(*) FROM finding_catalog_item_inventory_families m JOIN inventory_families f ON f.id=m.inventory_family_id WHERE f.level='INSTALLATION')::integer installation_mappings,
(SELECT COUNT(*) FROM finding_catalog_item_inventory_families m JOIN inventory_families f ON f.id=m.inventory_family_id WHERE f.level='SUBINSTALLATION')::integer subinstallation_mappings,
(SELECT COUNT(*) FROM assets y JOIN asset_types t ON t.id=y.asset_type_id JOIN assets a ON a.id=y.parent_id WHERE lower(t.code)='yacimiento' AND (y.operational_area_id IS DISTINCT FROM a.id OR y.operator_company_id IS NULL OR y.concession_type_id IS NULL))::integer invalid_fields`);
const expected:Record<string,number>={companies:13,departments:7,areas:64,fields:230,concessions:2,installation_families:14,subinstallation_families:109,findings:181,installation_mappings:48,subinstallation_mappings:880,invalid_fields:0};
for(const [key,value] of Object.entries(expected)) if(Number(row?.[key]??-1)!==value)throw new Error(`Authoritative inventory verification failed: ${key}=${row?.[key]} expected=${value}`);
}
}
@@ -1,6 +1,7 @@
import { Transform } from 'class-transformer';
import {
ArrayMaxSize,
ArrayMinSize,
ArrayUnique,
IsArray,
IsEnum,
@@ -41,6 +42,7 @@ export class CreateInspectionActDto {
observations?: string | null;
@IsArray()
@ArrayMinSize(1)
@ArrayMaxSize(200)
@ArrayUnique()
@IsUUID('4', { each: true })
@@ -5,16 +5,10 @@ import {
InspectionVisitActsController,
} from './inspection-acts.controller';
import { InspectionActsService } from './inspection-acts.service';
import { MobileInspectionActsController } from './mobile-inspection-acts.controller';
import { MobileInspectionActsService } from './mobile-inspection-acts.service';
@Module({
imports: [AuditModule],
controllers: [
InspectionVisitActsController,
MobileInspectionActsController,
InspectionActsController,
],
providers: [InspectionActsService, MobileInspectionActsService],
controllers: [InspectionVisitActsController, InspectionActsController],
providers: [InspectionActsService],
})
export class InspectionActsModule {}
@@ -656,7 +656,6 @@ export class InspectionActsService {
visitId: string,
assetIds: string[],
): Promise<void> {
if (assetIds.length === 0) return;
const [row] = (await manager.query(`
SELECT COUNT(*)::integer AS count
FROM inspection_visit_assets
@@ -664,7 +663,7 @@ export class InspectionActsService {
AND asset_id = ANY($2::uuid[])
AND included = true
`, [visitId, assetIds])) as Array<{ count: number }>;
if (Number(row?.count ?? 0) !== assetIds.length) {
if (assetIds.length < 1 || Number(row?.count ?? 0) !== assetIds.length) {
throw new BadRequestException({
code: 'INSPECTION_ACT_ASSET_INVALID',
message: 'Cada inventario del acta debe estar incluido en la inspección',
@@ -1,18 +0,0 @@
import { Controller, Get, Param, ParseUUIDPipe, Query } from '@nestjs/common';
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
import { ListInspectionActsQueryDto } from './dto/list-inspection-acts-query.dto';
import { MobileInspectionActsService } from './mobile-inspection-acts.service';
@Controller('inspection-visits/:visitId/acts/mobile')
export class MobileInspectionActsController {
constructor(private readonly acts: MobileInspectionActsService) {}
@Get()
@RequirePermissions('inspection_acts.read')
list(
@Param('visitId', new ParseUUIDPipe({ version: '4' })) visitId: string,
@Query() query: ListInspectionActsQueryDto,
) {
return this.acts.listForVisit(visitId, query);
}
}
@@ -1,102 +0,0 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';
import type { ListInspectionActsQueryDto } from './dto/list-inspection-acts-query.dto';
/**
* Read model intentionally kept small for the field client.
*
* The Android app only needs the Act identity/lifecycle, deadlines and counters to
* open an Inspection. It must not fail because an office-document/report module is
* unavailable or temporarily schema-drifted. The canonical office endpoints keep
* their richer projection in InspectionActsService.
*/
@Injectable()
export class MobileInspectionActsService {
constructor(private readonly dataSource: DataSource) {}
async listForVisit(visitId: string, query: ListInspectionActsQueryDto) {
const [visit] = (await this.dataSource.query(
'SELECT id FROM inspection_visits WHERE id = $1::uuid',
[visitId],
)) as Array<{ id: string }>;
if (!visit) {
throw new NotFoundException({
code: 'INSPECTION_VISIT_NOT_FOUND',
message: 'Visita de inspección no encontrada',
});
}
const conditions = ['act.visit_id = $1::uuid'];
const parameters: unknown[] = [visitId];
const add = (value: unknown): string => {
parameters.push(value);
return `$${parameters.length}`;
};
if (query.search?.trim()) {
const search = add(`%${query.search.trim()}%`);
conditions.push(`(act.code ILIKE ${search} OR act.title ILIKE ${search})`);
}
if (query.status) conditions.push(`act.status = ${add(query.status)}`);
if (query.year) conditions.push(`act.act_year = ${add(query.year)}`);
if (query.dateFrom) conditions.push(`act.occurred_at::date >= ${add(query.dateFrom)}::date`);
if (query.dateTo) conditions.push(`act.occurred_at::date <= ${add(query.dateTo)}::date`);
const where = `WHERE ${conditions.join(' AND ')}`;
const [countRow] = (await this.dataSource.query(
`SELECT COUNT(*)::integer AS total FROM inspection_acts act ${where}`,
parameters,
)) as Array<{ total: number }>;
const total = Number(countRow?.total ?? 0);
const limit = add(query.pageSize);
const offset = add((query.page - 1) * query.pageSize);
const data = await this.dataSource.query(`
SELECT
act.id,
act.visit_id AS "visitId",
act.code,
act.status,
act.occurred_at AS "occurredAt",
act.title,
act.summary,
act.observations,
act.urgency,
act.deadline_days AS "deadlineDays",
act.deadline_day_type AS "deadlineDayType",
act.deadline_basis AS "deadlineBasis",
act.deadline_base_at AS "deadlineBaseAt",
act.deadline_at AS "deadlineAt",
act.locked_at AS "lockedAt",
act.locked_sha256 AS "lockedSha256",
act.sealed_at AS "sealedAt",
act.current_version AS "currentVersion",
act.closed_at AS "closedAt",
act.closure_sha256 AS "closureSha256",
COALESCE((
SELECT COUNT(*)::integer
FROM inspection_act_assets link
WHERE link.act_id = act.id AND link.included = true
), 0)::integer AS "assetCount",
COALESCE((
SELECT COUNT(*)::integer
FROM inspection_findings finding
WHERE finding.act_id = act.id AND finding.status <> 'VOIDED'
), 0)::integer AS "findingCount"
FROM inspection_acts act
${where}
ORDER BY act.act_year DESC, act.act_number DESC
LIMIT ${limit} OFFSET ${offset}
`, parameters);
return {
data,
meta: {
page: query.page,
pageSize: query.pageSize,
total,
totalPages: total === 0 ? 0 : Math.ceil(total / query.pageSize),
},
};
}
}
@@ -1,9 +1,9 @@
import { Transform } from 'class-transformer';
import {
IsEmpty,
IsISO8601,
IsOptional,
IsString,
IsUUID,
MaxLength,
} from 'class-validator';
@@ -17,15 +17,15 @@ export class UpdateInspectionVisitDto {
objective?: string | null;
@IsOptional()
@IsEmpty({ message: 'El Yacimiento se fija al crear la Inspección y no puede modificarse' })
@IsUUID('4')
scopeAssetId?: string | null;
@IsOptional()
@IsEmpty({ message: 'El Área se fija al crear la Inspección y no puede modificarse' })
@IsUUID('4')
operationalAreaId?: string | null;
@IsOptional()
@IsEmpty({ message: 'La Operadora se fija al crear la Inspección y no puede modificarse' })
@IsUUID('4')
operatorCompanyId?: string | null;
@IsOptional()
@@ -61,7 +61,7 @@ export class F3FieldInventoryStructureService {
principal: AuthPrincipal,
) {
const base = await this.fieldInventory.types(visitId, parentId, principal) as unknown as FieldTypesBase;
const effectiveParentId = parentId ?? base.parent.id;
const effectiveParentId = parentId ?? base.context.area.id;
const parent = await this.parent(effectiveParentId);
const expectedTypeCode = STRUCTURAL_CHILD[parent.typeCode.toLowerCase()];
const data = expectedTypeCode
@@ -10,9 +10,9 @@ import { validateInspectionPlanningScope } from './inspection-planning-scope';
import { InspectionVisitsService } from './inspection-visits.service';
/**
* Creación WEB. La ubicación territorial es Departamento → Área → Yacimiento.
* Empresa y Tipo de concesión pertenecen directamente al Yacimiento; por eso
* la Operadora elegida debe coincidir con el Yacimiento seleccionado.
* Creación WEB F6.1. Separa explícitamente ubicación territorial y alcance:
* Departamento → Área → Yacimiento. La Operadora no es un padre físico; se
* valida contra la relación temporal vigente del Área para la fecha planificada.
*/
@Injectable()
export class InspectionPlanningCreateService {
@@ -52,27 +52,27 @@ export class InspectionPlanningCreateService {
stage = 'operator:validate';
const [context] = await manager.query(`
SELECT yacimiento.id
FROM assets yacimiento
INNER JOIN asset_types yacimiento_type ON yacimiento_type.id=yacimiento.asset_type_id
INNER JOIN assets company ON company.id=yacimiento.operator_company_id
SELECT relation.id
FROM area_company_relations relation
INNER JOIN assets area ON area.id=relation.area_id
INNER JOIN asset_types area_type ON area_type.id=area.asset_type_id
INNER JOIN assets company ON company.id=relation.company_id
INNER JOIN asset_types company_type ON company_type.id=company.asset_type_id
WHERE yacimiento.id=$1::uuid
AND yacimiento.parent_id=$2::uuid
AND yacimiento.operator_company_id=$3::uuid
AND lower(yacimiento_type.code)='yacimiento'
AND yacimiento_type.is_active=true
AND yacimiento.information_status<>'INACTIVE'
AND yacimiento.concession_type_id IS NOT NULL
WHERE relation.area_id=$1::uuid
AND relation.company_id=$2::uuid
AND relation.relation_role='OPERATOR'
AND relation.valid_from <= $3::timestamptz
AND (relation.valid_until IS NULL OR relation.valid_until > $3::timestamptz)
AND area_type.operational_role='AREA'
AND company_type.operational_role='COMPANY'
AND company_type.is_active=true
AND area.information_status<>'INACTIVE'
AND company.information_status<>'INACTIVE'
LIMIT 1
`, [scopeAssetId, dto.operationalAreaId, dto.operatorCompanyId]) as Array<{ id: string }>;
`, [dto.operationalAreaId, dto.operatorCompanyId, plannedStartAt]) as Array<{ id: string }>;
if (!context) {
throw new BadRequestException({
code: 'INSPECTION_OPERATOR_NOT_RELATED_TO_YACIMIENTO',
message: 'La Empresa seleccionada no es la Empresa relacionada con el Yacimiento',
code: 'INSPECTION_OPERATOR_NOT_ACTIVE_IN_AREA',
message: 'La Operadora seleccionada no está vigente para el Área en la fecha de la Inspección',
});
}
@@ -129,6 +129,8 @@ export class InspectionPlanningCreateService {
VALUES ($1::uuid,$2::uuid,true,$3::uuid)
`, [visit.id, dto.leadInspectorUserId, principal.userId]);
// El checklist sigue el alcance físico del Yacimiento. No usa
// assets.operator_company_id, que en F6 es sólo snapshot histórico.
stage = 'checklist:insert';
await manager.query(`
INSERT INTO inspection_visit_checklist_items (
@@ -195,7 +197,7 @@ export class InspectionPlanningCreateService {
)
SELECT DISTINCT
$1::uuid, item.asset_id, 'AUTO_INCLUDED', NULL, $2::uuid,
jsonb_build_object('generation',1,'source','AUTHORITATIVE_CREATE')
jsonb_build_object('generation',1,'source','F6.1_CREATE')
FROM inspection_visit_checklist_items item
WHERE item.visit_id=$1::uuid
AND item.generation_number=1
@@ -222,7 +224,6 @@ export class InspectionPlanningCreateService {
operationalAreaId: dto.operationalAreaId,
scopeAssetId,
operatorCompanyId: dto.operatorCompanyId,
operatorSource: 'YACIMIENTO',
plannedStartAt: plannedStartAt.toISOString(),
leadInspectorUserId: dto.leadInspectorUserId,
},
@@ -242,7 +243,7 @@ export class InspectionPlanningCreateService {
const technical = error instanceof Error
? `${error.name}: ${error.message}${error.stack ? `\n${error.stack}` : ''}`
: String(error);
this.logger.error(`inspection create failed at ${stage}: ${technical}`);
this.logger.error(`F6.1 inspection create failed at ${stage}: ${technical}`);
throw error;
}
}
@@ -67,34 +67,27 @@ export class InspectionPlanningHierarchyService {
at?: string,
): Promise<{ data: InspectionPlanningHierarchyItem[] }> {
await this.requireType(areaId, 'area', 'El Área seleccionada no es válida');
if (at) {
const effectiveAt = new Date(at);
const effectiveAt = at ? new Date(at) : new Date();
if (!Number.isFinite(effectiveAt.getTime())) {
throw new BadRequestException({
code: 'INSPECTION_PLANNING_DATE_INVALID',
message: 'La fecha de planificación no es válida',
});
}
}
// Modelo autoritativo: la Empresa pertenece al Yacimiento. Para mantener el
// selector de planificación por Área, se proyectan las Empresas de todos los
// Yacimientos activos que pertenecen a esa Área.
const data = await this.dataSource.query(`
SELECT DISTINCT company.id, company.code, COALESCE(profile.legal_name,company.name) AS name
FROM assets yacimiento
INNER JOIN asset_types yacimiento_type ON yacimiento_type.id=yacimiento.asset_type_id
INNER JOIN assets company ON company.id=yacimiento.operator_company_id
INNER JOIN asset_types company_type ON company_type.id=company.asset_type_id
LEFT JOIN organization_profiles profile ON profile.asset_id=company.id
WHERE yacimiento.parent_id=$1::uuid
AND lower(yacimiento_type.code)='yacimiento'
AND yacimiento_type.is_active=true
AND yacimiento.information_status<>'INACTIVE'
AND company_type.operational_role='COMPANY'
AND company_type.is_active=true
SELECT DISTINCT company.id, company.code, company.name
FROM area_company_relations relation
INNER JOIN assets company ON company.id=relation.company_id
INNER JOIN asset_types type ON type.id=company.asset_type_id
WHERE relation.area_id=$1::uuid
AND relation.relation_role='OPERATOR'
AND relation.valid_from <= $2::timestamptz
AND (relation.valid_until IS NULL OR relation.valid_until > $2::timestamptz)
AND type.operational_role='COMPANY'
AND type.is_active=true
AND company.information_status<>'INACTIVE'
ORDER BY name, company.code
`, [areaId]) as InspectionPlanningHierarchyItem[];
ORDER BY company.name, company.code
`, [areaId, effectiveAt]) as InspectionPlanningHierarchyItem[];
return { data };
}
@@ -1,17 +0,0 @@
import { Controller, Get, Param, ParseUUIDPipe, Query } from '@nestjs/common';
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
import { InspectionPreventiveCandidatesService } from './inspection-preventive-candidates.service';
@Controller('inspection-visits')
export class InspectionPreventiveCandidatesController {
constructor(private readonly candidates: InspectionPreventiveCandidatesService) {}
@Get(':id/preventive-candidates')
@RequirePermissions('inspections.read')
list(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Query('search') search?: string,
) {
return this.candidates.list(id, search);
}
}
@@ -1,77 +0,0 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';
export interface InspectionPreventiveCandidate {
id: string;
code: string;
name: string;
typeName: string;
}
@Injectable()
export class InspectionPreventiveCandidatesService {
constructor(private readonly dataSource: DataSource) {}
async list(visitId: string, search?: string): Promise<{ data: InspectionPreventiveCandidate[] }> {
const [context] = (await this.dataSource.query(`
SELECT
visit.operational_area_id AS "operationalAreaId",
COALESCE(visit.scope_asset_id, visit.operational_area_id) AS "scopeAssetId"
FROM inspection_visits visit
WHERE visit.id = $1::uuid
`, [visitId])) as Array<{
operationalAreaId: string | null;
scopeAssetId: string | null;
}>;
if (!context) {
throw new NotFoundException({
code: 'INSPECTION_VISIT_NOT_FOUND',
message: 'Visita de inspección no encontrada',
});
}
if (!context.operationalAreaId || !context.scopeAssetId) return { data: [] };
const term = search?.trim().slice(0, 200) ?? '';
const rows = (await this.dataSource.query(`
WITH RECURSIVE scope_tree AS (
SELECT root.id
FROM assets root
WHERE root.id = $2::uuid
UNION ALL
SELECT child.id
FROM assets child
INNER JOIN scope_tree parent_scope ON parent_scope.id = child.parent_id
)
SELECT
asset.id,
asset.code,
asset.name,
asset_type.name AS "typeName"
FROM scope_tree
INNER JOIN assets asset ON asset.id = scope_tree.id
INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id
WHERE asset.id <> $2::uuid
AND asset.operational_area_id = $3::uuid
AND asset.information_status <> 'INACTIVE'
AND lower(asset_type.code) IN ('instalacion', 'subinstalacion')
AND NOT EXISTS (
SELECT 1
FROM inspection_visit_assets linked
WHERE linked.visit_id = $1::uuid
AND linked.asset_id = asset.id
)
AND (
$4::text = ''
OR asset.code ILIKE '%' || $4::text || '%'
OR asset.name ILIKE '%' || $4::text || '%'
OR COALESCE(asset.common_name, '') ILIKE '%' || $4::text || '%'
)
ORDER BY asset_type.name, asset.name, asset.code
LIMIT 100
`, [visitId, context.scopeAssetId, context.operationalAreaId, term])) as InspectionPreventiveCandidate[];
return { data: rows };
}
}
@@ -10,25 +10,17 @@ import { FieldInventoryController } from './field-inventory.controller';
import { FieldInventoryService } from './field-inventory.service';
import { InspectionPlanningCreateService } from './inspection-planning-create.service';
import { InspectionPlanningHierarchyService } from './inspection-planning-hierarchy.service';
import { InspectionPreventiveCandidatesController } from './inspection-preventive-candidates.controller';
import { InspectionPreventiveCandidatesService } from './inspection-preventive-candidates.service';
import { InspectionVisitLifecycleService } from './inspection-visit-lifecycle.service';
import { InspectionVisitsController } from './inspection-visits.controller';
import { InspectionVisitsService } from './inspection-visits.service';
@Module({
imports: [AuditModule, AssetMasterModule, InspectionFindingsModule],
controllers: [
InspectionVisitsController,
InspectionPreventiveCandidatesController,
FieldInventoryController,
FieldFindingsController,
],
controllers: [InspectionVisitsController, FieldInventoryController, FieldFindingsController],
providers: [
{ provide: InspectionVisitsService, useClass: F4InspectionVisitsService },
InspectionPlanningCreateService,
InspectionPlanningHierarchyService,
InspectionPreventiveCandidatesService,
InspectionVisitLifecycleService,
FieldInventoryService,
F3FieldInventoryStructureService,
@@ -1 +0,0 @@
export default 'eNqtfUtz5MiR5l+B4bBWZVZdlQgAAUA3MAmS6M1X54Pq7hnZGCqJYqM3maDyUdZVMh103MMctPMLdJyDDmO6jc2Nf2w9gCQZAQQSHwo6yMSu9PgCCHeP8Fc4/mTepY/J7vCQbg9783f/8iczuzN/Z70zt/nDx11q/s5cJFtjmOw2+d7887vyZyb9PM8+J3fJ5yx5+dWWfh0df33629a4S43h8Uv+QuJIJONkk+zun/4nffnVrcw+Tz4l6eblZy79vDw+Hrf3yeEV2pNnT4xZ8tX88x/emev84THZZqn+HWfpYff0j02a743F8S55SHfZOtnSf0XbdHf/xVi8D9/r3346vFF/ld9+tYyMNz+s4skynIRG9L9mRji/jibLmP5r8X7+fvT+nfH68ySaX/9kxJPbaLEcE9WiAH5nXIf013I6D68jI5xcGuP4crGcR+HYWETz23gYPRMOp+NZ+PTXp38PjeXiovjHt9olX2RbI3p43KX7xJg+prvkLt8l2uW/SQ4HWov/Iy/E4r2eFcn2PtcumMyS23SbZxv1d1/6/afZlfpjILNpOFd/tAbyr5vj/pEYmVfgLZnT0fvx++i9+rvM0NkvebrNfjOuN/nHZGPM031+3K3TfQVS5vL1l2tjKt4p3X3O1umJsyep29LYfZZvl18eG2Qv+u1xkx+Sdfb0j61WxgqC3TMBoSa7NFGwShVOhA7n/1bFv82+rpP1L8RlRYFrY5iqOJ/ph2SraHVtiLwKF8lul2zX0iSOZoQsheH9MTEu6NXvFNk7P2ZzyB43pMjrLKVdZUP6/eW4leXx7HuF9EO2/+Mxk2WzNsJVRhxpFpkFxiTfHVJZeAGAxXH3CpJvZekGRhczGlpBERpwli9DwfvjIVHU4ewaD5NfxWvSnk17Pm39pAfy5m+x1vGbh+M6V5SlZcDTfyV3Ys7wIdllNNvrWKeFo8P0E23WW2lEmwgN090uNy5zmtP4/iiLuMWhoVfEjSMdDtKcHjRwnG/oqHsd5beN+oV4l/xyTLdCfl7H6YSGq+Po5Wh3N25oXtrcJcUftM6Z/XLM7vKC/YmxSDaZtELM+pbhytMz1hEiE+9gTNJ76YhirSI1MW7j2AhfRzitI767faVuFaJ8+2lDjFlnlbdrE6FoY4yT7ddEkljmtY+Zkcwp50PbtkNjFvl+mxL3XwcFLRNd5x/T3VZYA0b4hcyy182/TWy+T4QFt6HdgkwKYtwsS+8kftltckMHzgWdaq8DWMv7FabdJn+QhrTJxCjb3qW7XPd4bcIx2oijbZtKO5PdJiIjejhjSHxLaNbk1Xy1eduriYGl8I9l5bM9ZEZiwmX2OduLWaWn1cmLYirTVr/Md3di990b03QvHXF224YzFobkbbq7ex3jDFreUryaUe6L4YZOZDLA//Yq306bwMyS/WmH2BtketzR/6nnssNaEb48/fe28qqO3fKqpeTQsZiLs5ym/S7aH3bH9eFIm1S8PaS7h/ROcoacNtGa0QGfSi/uttPvD7nY2bcJyfPX15EcG0mnkFj6ymp5be9dDp7lX3PBtTVtEsZ0vc7uhJn4KtyO/y04u6yCEmCvMs9/lexNd9A6ansg+U4Kw/HmmG3Xssi5Vsujz9NPx/ssN+o+p9sma3MaVphyr0PsFsNmXsy0PX5JpEdsE6bTYRl+PW6UY8l1sYFiYSbpZ/nQcNvE6pbMw9IWF498vRNS+Tq6zb6+Te+T2iC/ZW1+TsiZl9YyAM0vYQsZkazvfNBuSMRkSKjawtt2p3mypS1tUx3WJiYX+SZV923eeqjRbj1Ldpvy51dl4A5uXJ6ekjzLT1m6udO7qaWRTiJCG51Z+qDPrmZahhKe/5M8tPzfSs+XXJ3yX3V+rVDHRN3DZWDWCVjvikpwdic4eaVW97SKKXlR3w7nNri9EqCjArLzgLzdJ5awXRXbPo/t1TRnlNGWt06+HdKvQQp7Pv92wEAPWGx63wyqRJNeQwUSHu/EdSXw9IJXfcaOmIrzS4KefU02QonCj8mveQ9cW3UefjimD+IY+HbA6i4ltDIlf1DsUt+O6tatgHDzKdn1eVIlilkchPS0m9oO0hHVa0YttHRMdmKfp/bP4Jc7+rdjB/oQlIzoqYiOFpHJcYd6SEzG81U8twXPqp6B+boqWR2fkGlM5UwYJkkPULstlpcrG3WgovOWw9TRHXonj+abzyrmalEr7OqIyZWc0HYjjv7vnrevHrheFyXoiO3roqiy5TNQ8bwWvECNseb1EKsMbnWyB+yBDrx09frAWrVz9tVG7QHLKlEL0t90I7zp0jrvgWwrLtSWyF6Dyj1gdfFtGY512hZst2ZYNz2n3cmCsXmNXycZm2R3FQHriOyp9sHwl2x7n/d7WL8S48zIIdmq20FXyEAPWd29OsI6AzXyOCJv4Pg16YOoZJ8Tsg3zXS885fQSG9/XY9qLO45Gk4qdtReoUwclAY3262STb3stgKtLCsl4TjcHkKssp0ekBc0qJ0BHTE8TCNtl6aHXg/pnc1sysNtpk3IUI3B7yNbiMNkYRZQ5POySzwSkGkdWxcz0W7ziwZncWg9Yqxm2bhx1BWdnwKu7TEdoW2OBJtu9CFn2gVWCdflmnWyNRSJCL2M6b3ohuw0pzrrpZVXs5qAFmTcEezX864jsNT1zb2S/OXUro/rd1jg4n9iVkbt5D3zQmvaVo3GDTtsxt1qTwsN0exBxmz6zMHSWRZ9Z7NZZavLedQosh95jAtWp+zU3hNM8yj+qIsSsbqi882N3nMBDJijshz6z+PVkOxkmVVekK2rFFv05qVgkHfG8gdZ2GuYirNYH1zqD25N/Xv20FHI3Tjeb7GtF9FinfdGTlfIy3X/KNoVXalwkX/I+uM4Z3LpIdASvVGiM812vZ+UVuU2yfbLthSjr2/WRzLtNHzRftaGH+aZiMHYFDCpeU5HT6wHoD85l1nvgWk159z6gmghlUQPTh+O+rauakvFsBO/Fa/BrNVUyltMp';
@@ -1 +0,0 @@
export default 'yOu7ZyquZFi3k+Hjy2pzcSQpmqebz8cHBZJ3iu35nrJrkCqmh0x1Yroi+pUA9zi/zw/pvg9kUIEsnYFemMGgWtKz/CXNeiFamjz6sM9SBtWyilO5QQ9EJaxPpxodDcNcmCfputeTOnXc8rzcHtd5r1V19TWIPRC5FrHuRnXF9fS4VZ3viFo18ZJ9NdTaFVHRpuxoDI9k1Gx7IFqDgbYKUATFH0m19r2wNScSHck/kHYlvXCZ1nauO0Rex1S83VDMKmP6nRx0a1C5SUBCVYlgs4oLLVI85yHrIZCb5CFZ61zCztj1YP7NMdkeN/1QvQbUmoZ1Rq7oGHkQa3Iw+2EGWsx/wuoqxR768uW6ud95Eqt9kr7LbjFATOxBV1RZ/35K1tmDqNEUx9zNwki3xjA1LtJt+ilb04H6/XDWb7JKtbu29qY7qqt/hYjM3OINisjQP/EtuDrfe4M4fqwzvJj7Iu0/n9dZuLrP4evXkI6OcPOY7O6FE/ZPeyHVvXt4fC4q7wPKBpprBjKg1e0QUStPNsI0qduTnUGZ7nQmCz29r1QxdgXW5RSOT/+Vf+2F6lRu6F6JIpFsrxq/nVH19ztkRNYRkVcs/5t8WzEhOhbfeW0Vgl0BfU2J7WWlxLYrZuXMvCC/kWzdbQ9IpbzktTpQcwx3xLVU3Hm+T3oxSKksEXppXCS/ijUti4L6INtqaGybrHNNQrwrauUS1O3p1m0PRFeLaPyrGf6r2QeX63GLY7QSJuyI7OmRa9ZXR1hfD9tbZgM9btWO61gtqx5Te9qqdg/ZfZ+SeEspMqnffpM3VhsJzEnI7ByypvKuK759Fr9q1XRFb7jaJ0M63U4axz1z80/G7VaDYTl1H/B0Wy5R9nO7WuDbKhvemRuHvYD9usWxEYGG9OOuEv3vjh1UHzpSbjvK2F43mXAHLZchZWy/GxOVghTprqQMGXQsg2f6q5SisFwrIM6gm0ArxSjiygqdoeEu3dIBrcBanbINllKMIhUVFdd/hPNyqIblOs+gRGSOu11mDCv2VGfISqgz3CQPtYBkZ9DKqVcubqXArDOof/aKbC/oALl922MCrs1vn1a7F7Baa/nluK2rtMM6pZAspfKkdidYQba7BVS5Xb8cLMM53fYe7oAZTsftuKqa4q9kk2zqN7scjjyyhMxrN4fIYd33gvTaEsedEX0dYs1m7QwbtFzY7oGtKyCpOzCdUS38lrg8jddxGnZ+GgXb76bMSi2J0qpFBg26HaCeRvHCu/xj2gvUbboE3weUV+Mue8JS00LuoCO7PG09ulHcHMizPx7TXuj+2Vv9quXTGTyoXtaN9kR7l6nGq1u9+Mza7usN0COjO7RVb1PQD5Bpe+z1grR1kPWTuDuwU7tjVweFAoevp6ZSeSJ3YpAx7Y73NfmZRg0ybscTXqk9qfdxkJE7Op9KDUqhCeKe8S6rSGu3onHLb7xXIIzIz1n1JlxX/EB7FW66SQ6V+svOwPV7Bc83GLNDP+R6Cq8MH/RDrR9rel+5M7BzBrgmzl3B63YlHRrXSc8VrsdRZsnukPVcYdWHuz9uS9WbZfmv/YD9aqAj3KieYWfEQIdYs1c7wrJ6yUoB208G2MCqoWrvG3XGZVXPgiShem28M6hdAR0mn5OekI4Wsj+33Erf3U15++6QbLP9Jsl7YXMNtqbLQWdcr5YUnhF65QJuZ1S/tSawM2SgheyrC0qdStEtSSzrKO+1hTOlMEXuwSRjVlw0y24DZZpk5mhDZ/rXatywY9cgy67U14oU6dfKBc+OkE597y6cn+xzryd1K3mc70Wrg30fRLUHWdm1eSRKL/qAehVhXaTrl7vi4upHskl1l+06zuLXm3bJAtYtjs6U+pBaF4na7tix3QMbnO0YJj930E3ZlJIRpaGY3KFl0LFdCWvsNyajWh1RbW07MhmRdUQ8161MxrU74rrnmpnJwE5HYK7rela0t9uuRQ/Wb9cHVtW62yz9tU+Klyn1I1InyuHu+LUh1dtxgqChlfi3r4JSTHKbiFYt42O6O3zTQvzhnZltyYDZCP8u314lD9mmqcv/RbiM5nGobbI+Ww61Lfxny1DbQn81iS/DS2MUDpfaxvnLeThZzKbzZaRtlj+T/l0xcKTHU4Jcy2ttR/x5NJsu4uV0Hk8bmuJPf54u9K3wh+HoMpqH+j748+jSGE4n9BpGPBlGk8tYhlFWaBROlqFxGRkRkUfXoaltAfZKNpuO4nE0F3jEvf3xYxsDy9+LZkm17vbD6Sga0vvLTD1HvwwnP6wimdXt1OVCTEeyIACjrsUXHcKRLB7AqNlqNH76j4ksNcCoaBTdhpdTWabOjVpEs3BO9HPjIr4KF/FwatyQDP1M7yk9sN8Z5DaaL+OhBBGgEMv5CUMR43NjL6bji9AYCqGLr1ay2FnIwIubpSL57SMuowUJ8c/hOKY5p0ahePHt1HhzuZqNoh/fKurRA46keRKPRtO3iiK1AxJkHF7Nw+txqHTD77KIxng1WsbRMpyFC6UvPjL9LBzRyIUxpZ3gmva/8PtoofTIP6vI4fw6pP1o+oEW5uU/lGb554bPYjF3sQvR3kKqN4zlN2iRw/GUFl0o0ZDWgeT4Tam7JJhvldb57Rih+ArLxWqxjKcTPUzbhkaLSFp4KW9pLeJZ3WiYRv5Y8x7IHIScjrpb9alcaNiQzkB1HIfGLf53PCZOKl3ygWFCq8LhzVTplA89prrFswAaVl16ewANq27ytgUNq+7yNjs/7GV7VZrknxtx9fup0hX/HK1GUu0WmbiZLo1pPFJ64Z+jL3YWpQH++Qc6u4XYLZLwoqxK4/vzE64uQ2HMLKVpnBYROO1U4SxWut2fZf88XJanZDQfy2ekoxEAu3H9HPs8dYPmOQ40rKoKjnt+2DAaXdJhQTv21Wi6DIe0YSq97M+NHcWTqBxLenu5GorB4r/C61VpYs6np39VOtyfg9QdIEpb+3ODr+LRci4ZyE7QsmTT+bzYq6Y/xjSV4G2o';
@@ -1 +0,0 @@
export default '9LE/N3gcXcY0ICrfvxBBpYl9dWyr26IRIler59Jx7trnB0ULMYKecGSQhS64sZwqfezPDSZX4ediTcQbLobzcCaJo+ueHzyPhtFsKQ9WRvOWl52vxAfV6BSJSABG8ht750eSzC2jUhKH4XJ6SXaw0tG+OpbXhG+hbApucH5M9MMqJqPRKDf5YUznSfHGT/8xIvFYKC3uz+G87pqGvG2WwjWm15HWgFvnsYr9ZqE0vD9LTg65TG6fJ6/YLVwjRV6NXIJ3z9OrmyXn56kX9OyjeBkJc/cyXpDdeLGKVR3h3nmIcLiKCsUwPhjFjmbKHYnOjezEM40c+TojQRrjDVrGjFbXQsqHN5Jqedb5QeLzhDSJOhFrGzO5jCYL5VT27PNjwtE4JI/m2bE6HRIliin3+TkLQssZTa7mcXVl3PPjTkJXHEan5/heCvXwltGnc4FQ4nIHFAymnWX28g+m3KvnHNY8uiBTS+aPf35ARXm9FrnRHgt+i+BEP96EK/ExS3lMi9wsSLuicWhMotU4XMrmj8+Q5bwe0ZiR8Sa8INm7pWeOXr0xv0WWbsgXl+ZrExqSt/nwRgrh6dzvQWXNo6kxjoZPf5kIzzOML0y5UU230XQEhVej6EdTbkyDQJSu79RYrITxE1+MXmXW91sghuFtYVSQmF4TX+Pb0HgzG86kRQ5aEBark8FlTMLlSjYig0HL0HjyUxH/k+QpsFrG0Bobb17XLhRL9/q0Aeu0ZJoVC+wWBFod40192aRncKAVk4LIgU7SzkV7A51wKe5QOJqRLTMfvpokgdcyprKFBH4LPYHX/IZAJy12sxlQtE05O0DjqBb9UM4OUq2Bos9JyyQ651N7IhfNTc6CnXVGij4mteHq15xEGvcuNR7zzdPfH+QPChUdS84ODu8zUXm2S6VPfg54y6CrbHPYlXNuEmOfb47lt1IfkrtdairNR87iXNAf4vsY+T77lK2L7xIXoMmp+vAuOyGbStsRCDM1su2XdF0+mMAUlUoiw1TBC1rwRElLsT4CZJ0c75INId9ln7O7Y5E2f8x39L+veTnfId+dvq1ENNt7fWqqMP+eVYHMQXJZyMnTp6pK2stTzCqe6ugV53QVnZyh4nPRUqZIfqvJ1JgSSljEoEUE15ifHAvJiVa6iNKPE/Lgv5uNQjp1pvMx/S1En2wO8a/xQmiAbN7LAl4+r5imos7Kx6LD0e1qRH7R7exWmxajs3ZWvFp8KT6rfUU+l/K8gaqgZLErCUB1S42e/hqOyMoqXLjJFQG/mdMmIMfh1Q316a8CzXhz8nU/xBPlKd42fGF6JeiN0FjG0ZwYQwcBDRInwQdDuPaVocqeNI9FyORlQuNiNaKdhR7irT7pJhyTUGweJ1t4HE/i8dTUfk2H/Ob41V5eij2H1mEcjS9EKN/Ufi1HP4aMpnF8LbHBUt2sYcGyUTyexdHPoan9og1tmiRfxnN6rxhxOVetaaUeZDqnxTd+qsMqdR0niSJ9uZr+3tR+TeZF6EQcKRbWeFVEldKL63l8JbzwkJ5YcFaWeKWaorA8p6b24y0nhtYC+a5Wfktdvl4Jabg0td9YqQiZqf1gioZ7clBaqS0gJS/ZJnBHIsJgaj9scvv0l3KFtc+opP7H4WRahE4XpvZ7I8+8Enwgc6f444o8OTX+La3haesxtR8DUZeYLNRyB1q8NbUf+dCvjfEdneaTU5rru1JGFzrRtBs4R1sE7Q60x4am9psdJ19ncQpingJKH65X4fxSaAP5M2o8vOYlSas1MsoYkan94oZwiovNJhJxp8jUf0NjL2yB4rRcr9O99IFBpWFBTEdbcVrTAb2TjtjPZbegN+Nke5+8NfVfv8j25b3ltahpK0rI31zvks+JWOy0KBSiv6ZkXrw1tZ+7GD797SE52QlkbWyTX9XvuaoXphIjMQ7lLYk363yb/lY8a7p5+s/1YUcv8dbUfqxinByST8f0nmycN8Nke8jukjt6rGX2KD+WzPV4c3zItpXPtauflXj6+zZNynW6S0ztZyJq+69yv35Moihtdcr9+GE0J4+QxLzIT8gx4oE+aimOISO8jUzthxSicbw4Rdmvw4UcF1UOt116yNSvnL789vnpb5vPR2EepvfHnVhAU/uNgot8XSzKTfkP+q8NJI+H5EXM7uWvwMorfFmKQyEan0Tvn3dGWkr0FxK3koum9tMA8VYUU5VlasWnlMjoU1mp3LWukpNkkcBuiLF7sglPk5rapv7CyDLm4U+VkK28TZZ7321xhpvaFvuach0yD6Ifl2ITU2KSTBekN3QJFVPbAH+Zkol7fEx3n0XTtC3Nss/2Bu29kvWpXDom8/NahEjnkZwCUK4PL9PtvjCo062x/iWjZSzVIt2vk8f0xGHJH1FuCFdtnHkRu6c/QmMqDpdyE3z6f2pctmqJmtpm8KdTwNT2dCdzekYzPv37pPTyRtFVOCyPiXJhTW3r9memFGZrrbTKU4ub5vPpRUjmz/zFNDTeCaOIbCgCCIu3FOeoqe24LiVhqm+i3HmdPf3f8jA0tW3Qy/qON2Rjj43Sw39r6luaV8/YZ9wPxvU8HM+UgYq58vSfO3HBcv/OOG5JqkgUPhY7hPgX8iZPfz3vIPTnY3YgZ+q9qe1cHov7xoUDqaqsp4YVST/EoUneDjk/5d+mttW4bFvK+62v42slslmxaE1tN++hqJsSfsPlKWIwii9K14u0Xg30qA27hYAsFKtNuQ55WnVT21FbdXiEkKhZOuXCYoW1Lx7nwtT2wH6xkj4Yw6e/FGaSqW1tXZipJQMWUqmlekkwXoykBNcqGk1NbQPqofhQ7ybdPf09Megk+iykgOyID8aiduIEddYsTG3HaGJqdF2YsfVQlYYXBcsWK5lKwxF6JvIH5OVT7r09K06RRhhGsRzo402p4El8Szx8c0FeOR36o5GsbcpNNbJmF8+DFqvvI9XsV+6eSTY4icotnSpvSkURG+5bU9sM+eR7VDwPtbvxbVYelR/W';
@@ -1 +0,0 @@
export default 'yUcRMjmcTgBT37B4nq7FkVMEZE7HK6n4vvgCjr4V8Szb5Ifc1PcTFi270pwOLjpu6N+Oe1F5muxMfafg8ujPny0NQ4hVKh/nahfgeXqf7Q9lv6qH01BT39T3hjyp8OdrubJWbc9byLPxup0VFnZaBpfEWZk8iDut+g68bYM3+TqpPFvlq8rr3KBFp+NYNHgzYnrr3ZaM8+RjsiMLLXkko/tQvOZHGpTmcpxkcA4p+k1F+ngy+Ion+2KkfzwqVrXaRfciv0vvE7Oh/W0RjSvmkZZFjdPmRVV5viPz6LdfEuJ9LjFeDaOK76eLZTP1PWWX4tYFWSmb8qa1vhHs8MWrKaKFD8XpZuqbuJLQpofSkSmETMb01faDu7tkXX75paF96lEAFKu6y++Oa5XT6ud4t+sNGcmFwVUsvfjrIMBNfdfTS+E9PaR6IVKCIxfp7qHoD/tQN4LV3qQL0pmUaBfrRHKF1E6jRSBXPFz8Esc19d1Db8WXzU7y/okc0vK7Eaa+Mehyl2z3n/Ldg8pqJVRyVQSLD8mj8DjWGfmBxdu/OHCiCWK6zsnl28rT+DobR4x8sWSKiPkJRrjOB3l4oAgbbZS70nH5uFH3HzWsktCfQgHUdTdy45N4CVPffHO2yw+S4/4oNo+98fD0j88kk5K8KiGX2+wUnS9iA0J1C4Pd1DfNvAkv4lG8PJ3kM7KAp/PrcBIvxlNjSsZPLEzGy+lwNRbZmoJKjoM6akRlEi2NZ0Q6octKo1lZwHcORi3ZWUVk2d7El/Onv6xGZH+9QNLZq31CU98QMxyF83FoLKYTOUWvtrY80VSsA7VP5WI6G52MwPORNLUN5YvBeLKRWsYqsZtT5eurBXGqozP1fSQlclHNoZgOal9I4Z4sjEk0jBbhXLk+okZlnmN7ixWxT6yyFOFVYjANUUC1+aLk8wxX4eUpBXeqyZaztGpvRe2wyjLowm2Lm9XSuJz+Xl4EX2cIm/ouhvPolqaOX2tVRHA6JHdezhcqERt9TL+8fFMOJU+lkr1UIjkNAHq/WWabEum5nNJiGqVMm/q2gfpgpqlvBni1Em7OQkRIo5lwisv/pKFlvt3Ud/ir+CU1r0Rt3hcOSb9KVBFWWSnXn5T4TyHbhVtUSKTYYcgFJOmYrYo1K95qcRXekr6LhXgjNpGw2EPGRYmJmIN4Yep785FxPJ5NS5rFyWAumS/49zqpqe+/95wyemesLopsE2nhO/IUSNVisX8JZ+Un4zW8M55O4sI1NaJitxM7pny/Y9C8okJ7Th5cGf6frWSGK1GoS/J/nrNfND/NF81vQ+0wpeg8Gt6UCU2RNqeBs5tiTN2hVrvjdV0FLaBi8MVj2jSHQvi1wRm16d1pF9TeN1EiVGFxnWdRcvtqTg7981b9kv581hF6dEnSlUBVnS+FtLwssbpZcXUTmtGEYjlKgZOHGG9eZZF2hhlJymntpFNTiXsVFe/PR+sLTPTjcLRayIkNteHcyQqRbZ8PvxZ34z9sMjJ/JBtDiYE9JNunf7zYM4/kcDw8/U1YHftPyed8J2whYXhsjT0Z/sVN3cIdkewcJSRGhmAqenkRTLI9JhtT3wVOBGXnalBW7ej2vP8335FUImNSduaUKpIpz2TuXrKPCyHK1R3Z8/Rpppk4ViYizWR8oGcrkvNLlTeKfIzCZ8U9sVTsfpcrZSpZBC7i6Sw+5UjL56SdZTwTyTwyX2RFULuexZWjUAmQSWv0mkzW3hGVKyleflPuRv0iaL/e569XjwE61kDHKnQ2SOeAdC5Ix0E6D6TzQboApLMGKKGFEqIssVCeWChTLJQrFsoWC+WLhTLGQjnDUM4wlDMM5Qxr4owNapUNapUNapUNapUNapUNapUNapWNapWNapWNapWNapWNapWNapWNapWNapWNapWNapWNapWNapWNapWNapUDapUDapUDapUDapUDapUDapUDapWDapWDapWDapWDapWDapWDapWDapWDapWDapWDapWDapWDapWDapWDapULapULapULapULapULapULapULapWLapWLapWLapWLapWLapWLapWLapWLapWLapWLapWLapWLapWLapWLahUHtYqDWsVBreKgVnFQqzioVRzUKo5qFUe1iqNaxVGt4qhWcVSrOKpVHNUqjmoVR7WKo1rFUa3iqFZxVKs89GWqhMxBCV2UkKOEHkroo4ToW9sDlNBCCZtY6KOc8VHO+ChnfJQzPsoZH+WMj3LGRznjo5zxUc4EKGcClDMBypkA5UyAciZAOROgnAlQzgQoZwKUM6KSCGNNjdK2YUoHpnRhSg5TejBlEyctC14lC14lC14lC14lC14lC14lC14lOH5ao2xeJQavEoNXicGrxOBVYvAqwfGwGmXzKtnwKtnwKtnwKtnwKtnwKsHxjRpl8yo58Co58Co58Co58Co58CrB/mqNsnmVXHiVXHiVXHiVXHiVXHiVYP+jRtm8ShxeJQ6vEodXicOrxOFVgh2bGqUNUzoDmNKCKRlMacOUDkzpwpSN3Kza0Y4HU/owZYBSuo08CtCghAVb3TVKtzFDOoBTzQN09hpl8+wWmrS34Mkt1JGoUboMprRhSgemdGFK+N1dD6ZsXKVa3UeAUvIBTGnBlI08QjPzDE3NMzQ3z9DkPEOz8wxNzzM0P8/gBD2DM/QMTtEzOEfP4CQ9g7P0DE7TMzhPz+BEPYM9Ewan6hmcq2dwsp7B2XqGpusZmq9naMKeoRl7hqbsGZqzZ2jSnsFZewan7Rmct2dw4p7BmXsGp+4ZnLtncPKewdl7Bru3DM7fMziBz+AMPoNT+AzN4TM0ic/QLD5D0/gMzeMzNJHP0Ew+g1P5DM7lMziZz+BsPoPT+QzO5zM4oc/gjD6DU/oMjpEwOKnP4Kw+g9P6DM7rMzSxz9DMPkNT+wzN7TM0uc/Q7D5D0/sMzu8zOMHP4Aw/g1P8DM7xMzjJz+AsP4PT/AzO8zM40MbgTD+DU/0MzvUzONnP';
@@ -1 +0,0 @@
export default 'PFTVPFTVPFTVPFTVPFTVPFTVPFTVPFjVPFjVPFjVPFjVPFjVPFjVPFjVPFjVPFjV4GhtjZLBPGIwjxjMo2ZV81FV81FV81FV81FV81FV81FV81FV82FV82FV82FV82FV82FV82FV82FV82FV82FVg+uKapQM5hGDecRgHjWrWoCqWoCqWoCqWoCqWoCqWoCqWoCqWgCrWgCrWgCrWgCrGpyPqVPC/LFgBlkwhyyYRRbMIwbziME8YjCPmm/hDdBreAP0Ht4AvYg3QG/iDdCreAP0Lt4AvYw3gG/jDeDreAP4Pt4AvpAHJx/rlDB/LJhBFswhC2aRBfOIwTxiMI8YzKNmVbNQVbNQVbNQVbNQVbNQVbNQVUMT0zXCZlWzYFWzYFWzYFWzYFWzYFWzYFWzYFWzYFWDq0drlAzmEYN5xGAeNasaXOlZo2ys6K5TujAlhyk9mNKHKeF3b6zrrlNaMGUjN+EL8Ta8nDa8nDa8nHCRa43ShSl5IyWaKq0RNi+SAy+SAy8SXONao3RhSnyRuANTujBl43qiKbYaIYMhGYzZWD5Zo+QeTIljws/pNW41cIzehothbbgY1oaLYW24GNaGi2FtuBjWhsOrNlwMa8PFsDZcDGvDxbA2XAxrw8WwNlwMa8PFsDYcP7ThS3c1ysYyTxsuXK1Reo3tElBn2IFdshqlA1M2VvfWKD0GU9owZWPLDdSNceD6Wgeur3Xg+loHrq914PpaB66vdeD6Wgeur3Xg+loHNvod+HqXA1/vcuDrXQ58vcuBr3c58PUuB63uddDqXget7nXQ6l4Hre510OpeB3U2HLi614Grex24uteBq3sduLrXgat7Hbi614Grex24uteBq3sduLrXgat7Hbi618F7ccHNuOBuXHA7LrgfF9yQC+7IBbfkwnty4U258K5ceFsuvC8X3pgL78yFt+bCe3Phzbnw7lx4ey68Pxdc3eu4aHDOgb3wOqWHUjbbE7Wyw8bn9FF/qEbpcZiy8Y1gP8OtmdCNvatQE7pG6MGU/gCmtGDKxkZXaKNcFw4Iu3CYt0bpwo/Z6BPUKR2Y0oUp4Xdv9AnqlI2rZKPh/Rplo9NYo/RxSnh2v3E9a+cwhykb19OFMV0Yk8OYcCOqGqXfyHd4/+JwUKNGacOUjZG0OqUFUzKY0oYpHZjShSkbVx5OO3K4aQ2Hm9ZwuGkNh5vWcLhpDYeb1nA4qlGj9GHKoFE+0WgBR6MFHI0WcDRawNFoAUejBRyNFnA4WsDhaAGHowUcjhZwOFrA4WgBh6MFHI4WcDhawOFoAYejBRyOFnA4WsDhaAFHowUcjRZwNFrA0WgBR6MFHI0WcDRawOFoAYejBRyOFnA4WsDhaAGHowUcjhZwOFrA4WgBh6MFHI4WcDhawOFoAYejBRy+cslhz57Dbak43JaKw22pONyWisMVA5yjHlGNMmjkpgdj1u54MZgSx2zkEXynokZpw5TNXoEPewU+7BXA8aQ6JbxKzV6BD3sFcIk9h5uxcrgbK8f9RrgfK4cbsnK4IyuHW7JyuCcrh5uyerDPXKNsbjI9gLtMw+2z6pQeTOnDlPC7N7eaHsC9pgcwj+A6hBpl0LjycBzTgysxPTjuVqMM4NmDxtnhskkP7R/iof1DPLR/iIf2D/HQ/iEe2j/EQ4sbPbh/iAf3D/Hg/iEe3D/Eg/uHeHD/EA/uH+LB/UM8uH+IBxuzHtw/xIP7h3hw/xAP7h/icVgpYTOtRtloyNcpHZjShSnhN2o05OuUjRKCXiz1YOvHg22aGmVjnqpOacOUDkzpwpTwuzfmqeqUjR9JqJ3BjZTwGVyjbKz49mvnZeM3Ghy0sNOvx2sGOKmFkzKc1MZJHZy08Zsb9SOR46QeTtooKhx/AI4/AMcfwMMfwMMfwMMfwEcTHX79Tn3j1zDqN30bv4dRv6nY+EWM+kdyG79WUs8lNEl2AMfuAtgSrmM22lAaUhcn7fAAHk7q46TwWlmNlpSG1MJJGU6KSwDDucVwbnUQF4Zzi+HcYji3bJxbNs6t5q8L1b0TGyd1cFIXJ+U4qYeT+jhpAJM2hkk1pBZOinPLwbnl4NxycG45OLcaW/MH8L26AL4DV6NsXlSORorrz9n4DQENKfxOVuM9Iw2phZMynLRRquC7fQH8wb0apQ3P3rwBebCceKj7W6f0YEofXs/mTb3eXc/BSRtfv95IjOOkHk7q46QBTNr47QENqYWTMpzUxkkdnBTnFse5xXFucZxbHOeWh3PLw7nVeNUyQDu1BWintgDt1BagndoCtFNbgHZqC9CAWgB3agvgTm0B3KktgDu1BXCntgDu1BbAndoCuFNbAHdqC/BPesKd2gK4U1sAd2oL4E5t1gC9p12nbP680QBOfNZJXYaT4m/lOjipi5PiK+B6OGnzYuFf5Bzgn+Qc4N/kHOAf5RzgX+Uc4J/lHODf5RzgH+askdo4afPH3eqkFk7KcFIbJ3VwUhcnbWasDW8u+PdBa6R8gKN6dgdapwNt83I5+OO6HR6Xd3hc/COHddIzb+bjsH4H2AAWmvoRz//8h3dmSbhJDkR5lW3vsu393vzdv/zJrCFoX6CNqrwO2IpVXoOsNVGFpmTQlAyb0oamtKEpbWxKB5rSgaZ0sCldaEoXmtLFpuTQlByakmNTetCUHjSlh03pQ1P60JQ+NmUATamvv2yjciAs/eMH3/j45e3ldjLo0Szw2cq7yK2ff2/Y8wbYpjfAVsQCd1pwqwX3WgvbbC1st7XA7dbC9lsL23AtcMe1sC3XwvZcS7fp/uHP/x8ocJi7';
@@ -1,41 +0,0 @@
import { inflateSync } from 'node:zlib';
import payload1 from './authoritative-inventory-model.payload-1';
import payload2 from './authoritative-inventory-model.payload-2';
import payload3 from './authoritative-inventory-model.payload-3';
import payload4 from './authoritative-inventory-model.payload-4';
import payload5 from './authoritative-inventory-model.payload-5';
export const AUTHORITATIVE_INVENTORY_MODEL_SOURCES = {
territory: {
file: 'modelo_yacimientos.sql',
sha256: '6a1d96bc8af7e755dd0e2fd9faf08335625b9c76865208c3b8413d868aa5eb1a',
},
inventory: {
file: 'modelo_relacional instalaciones y sub.sql',
sha256: 'fb513f3909e78db298361a8a6998ca77026748f9b03cbab44c77b884ca862b49',
},
} as const;
export type AuthoritativeInventoryModel = {
departments: Array<{ id: number; nombre: string }>;
companies: Array<{ id: number; nombre: string }>;
concessionTypes: Array<{ id: number; nombre: string }>;
areas: Array<{ id: number; departamento_id: number; nombre: string }>;
fields: Array<{
id: number;
nombre: string;
area_id: number;
empresa_id: number;
tipo_concesion_id: number;
}>;
installationFamilies: Array<{ id: number; nombre: string }>;
subinstallationFamilies: Array<{ id: number; instalacion_id: number; nombre: string }>;
findings: Array<{ id: number; nombre: string }>;
subinstallationFindings: Array<{ subinstalacion_id: number; hallazgo_id: number }>;
installationFindings: Array<{ instalacion_id: number; hallazgo_id: number }>;
};
const payload = payload1 + payload2 + payload3 + payload4 + payload5;
export const AUTHORITATIVE_INVENTORY_MODEL: AuthoritativeInventoryModel =
JSON.parse(inflateSync(Buffer.from(payload, 'base64')).toString('utf8')) as AuthoritativeInventoryModel;
@@ -1,50 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
AUTHORITATIVE_INVENTORY_MODEL as model,
AUTHORITATIVE_INVENTORY_MODEL_SOURCES as sources,
} from '../../src/reference-data/authoritative-inventory-model';
test('authoritative SQL sources keep their exact cardinalities', () => {
assert.equal(sources.territory.file, 'modelo_yacimientos.sql');
assert.equal(sources.territory.sha256, '6a1d96bc8af7e755dd0e2fd9faf08335625b9c76865208c3b8413d868aa5eb1a');
assert.equal(sources.inventory.file, 'modelo_relacional instalaciones y sub.sql');
assert.equal(sources.inventory.sha256, 'fb513f3909e78db298361a8a6998ca77026748f9b03cbab44c77b884ca862b49');
assert.equal(model.departments.length, 7);
assert.equal(model.companies.length, 13);
assert.equal(model.concessionTypes.length, 2);
assert.equal(model.areas.length, 64);
assert.equal(model.fields.length, 230);
assert.equal(model.installationFamilies.length, 14);
assert.equal(model.subinstallationFamilies.length, 109);
assert.equal(model.findings.length, 181);
assert.equal(model.installationFindings.length, 48);
assert.equal(model.subinstallationFindings.length, 880);
});
test('Area belongs only to Departamento while Yacimiento owns Area Empresa and concession', () => {
for (const area of model.areas) {
assert.ok(model.departments.some((department) => department.id === area.departamento_id));
assert.deepEqual(Object.keys(area).sort(), ['departamento_id', 'id', 'nombre']);
}
for (const field of model.fields) {
assert.ok(model.areas.some((area) => area.id === field.area_id));
assert.ok(model.companies.some((company) => company.id === field.empresa_id));
assert.ok(model.concessionTypes.some((type) => type.id === field.tipo_concesion_id));
assert.deepEqual(Object.keys(field).sort(), ['area_id', 'empresa_id', 'id', 'nombre', 'tipo_concesion_id']);
}
});
test('Subinstalación classifications belong to one installation classification and every mapping resolves', () => {
for (const subinstallation of model.subinstallationFamilies) {
assert.ok(model.installationFamilies.some((installation) => installation.id === subinstallation.instalacion_id));
}
for (const mapping of model.installationFindings) {
assert.ok(model.installationFamilies.some((installation) => installation.id === mapping.instalacion_id));
assert.ok(model.findings.some((finding) => finding.id === mapping.hallazgo_id));
}
for (const mapping of model.subinstallationFindings) {
assert.ok(model.subinstallationFamilies.some((subinstallation) => subinstallation.id === mapping.subinstalacion_id));
assert.ok(model.findings.some((finding) => finding.id === mapping.hallazgo_id));
}
});
@@ -32,17 +32,15 @@ test('F5.1 canonical hierarchy starts at Departamento and Area is no longer root
assert.match(migration, /SET can_be_root=false/);
});
test('Authoritative manual creation keeps Area physical and stores Company plus concession on Yacimiento', () => {
test('F5.1 manual creation permits Area context without requiring an operator', () => {
const migration = source('src/database/migrations/1790087400000-f5-1-clean-manual-inventory.ts');
const structure = source('src/asset-master/inventory-structure.service.ts');
const dto = source('src/asset-master/dto/create-inventory-structure.dto.ts');
assert.match(structure, /operator_company_id,concession_type_id,inventory_family_id/);
assert.match(structure, /dto\.kind === 'YACIMIENTO'/);
assert.match(structure, /INVENTORY_YACIMIENTO_COMPANY_REQUIRED/);
assert.match(structure, /INVENTORY_YACIMIENTO_CONCESSION_REQUIRED/);
assert.match(migration, /DROP CONSTRAINT IF EXISTS chk_asset_context_history_context_pair/);
assert.match(migration, /CHECK \(operator_company_id IS NULL OR operational_area_id IS NOT NULL\)/);
assert.match(structure, /operator_company_id,inventory_family_id/);
assert.match(structure, /asset_context_history/);
assert.match(dto, /operatorCompanyId\?: string \| null/);
assert.match(dto, /concessionTypeId\?: string \| null/);
assert.match(structure, /operational_area_id,operator_company_id/);
});
test('F5.1 Inventory browser exposes every canonical level instead of filtering by is_inventory_instance', () => {
@@ -66,27 +64,23 @@ test('F5.1 Finding Catalog defaults to associated findings and exposes all items
assert.match(panel, /Esta clasificación todavía no tiene Hallazgos asociados/);
});
test('Authoritative Configuration presents exact physical relationships and technical families', () => {
const configPage = source('../web-v2/src/pages/AuthoritativeInventoryConfigPage.tsx');
test('F5.1/F6 Configuration manages findings inline and filters Subinstallations by compatible Installation classifications', () => {
const configPage = source('../web-v2/src/pages/AssetTypesPage.tsx');
assert.match(configPage, /El Área sólo tiene Nombre y pertenece obligatoriamente a un Departamento/);
assert.match(configPage, /Tipo de concesión/);
assert.match(configPage, /Empresa relacionada/);
assert.match(configPage, /Yacimiento → Instalación/);
assert.match(configPage, /Instalación → Subinstalación/);
assert.match(configPage, /installationFamilies\.map/);
assert.match(configPage, /subinstallationFamilies\.map/);
assert.match(configPage, /Hallazgos asociados/);
assert.match(configPage, /replaceInventoryFamilyFindings/);
assert.match(configPage, /Agregar o quitar/);
assert.match(configPage, /familyFindingIds/);
assert.match(configPage, /selectedInstallationFamilyId/);
assert.match(configPage, /family\.parentFamilyIds\.includes\(selectedInstallationFamilyId\)/);
assert.match(configPage, /Filtrar por tipo de Instalación/);
});
test('Authoritative Web creation exposes Departamento as root and Yacimiento as operational owner', () => {
test('F5.1 Web creation and configuration expose Departamento as the physical root', () => {
const createPage = source('../web-v2/src/pages/InventoryCreatePage.tsx');
const configPage = source('../web-v2/src/pages/AuthoritativeInventoryConfigPage.tsx');
const configPage = source('../web-v2/src/pages/AssetTypesPage.tsx');
assert.match(createPage, /kind: 'DEPARTAMENTO'/);
assert.match(createPage, /AREA:'Departamento'/);
assert.match(createPage, /Empresa relacionada/);
assert.match(createPage, /Tipo de concesión/);
assert.match(createPage, /Datos opcionales/);
assert.match(configPage, /<strong>Departamento<\/strong>/);
assert.match(configPage, /<strong>Yacimiento<\/strong>/);
assert.match(configPage, /familyId=\$\{familyEditor\.id\}/);
});
+4 -4
View File
@@ -7,16 +7,16 @@ function mountedRepoFile(path: string): string {
return readFileSync(resolve(process.cwd(), '..', path), 'utf8');
}
test('Android 0.19.0 targets production API and keeps a distinct installable debug identity', () => {
test('F6.1 Android test cut targets production API and has a distinct installable debug version', () => {
const gradle = mountedRepoFile('android-app/app/build.gradle.kts');
assert.match(gradle, /versionCode = 28/);
assert.match(gradle, /versionName = "0\.19\.0"/);
assert.match(gradle, /versionCode = 24/);
assert.match(gradle, /versionName = "0\.15\.2"/);
assert.match(gradle, /https:\/\/dhv2\.korexlabs\.com\/api\/v3\//);
assert.match(gradle, /applicationIdSuffix = "\.debug"/);
});
test('F5/F6.3 field inventory exposes Other families as reviewable choices to Android', () => {
test('F5/F6.1 field inventory exposes Other families as reviewable choices to Android', () => {
const service = readFileSync(
resolve(process.cwd(), 'src/inspection-visits/f3-field-inventory-structure.service.ts'),
'utf8',
@@ -35,7 +35,7 @@ test('F6.1 uses contextual type catalog for Yacimiento and keeps OTROS/add-anoth
assert.match(androidFinding, /También podés registrar otro Hallazgo sobre el mismo Inventario/);
});
test('legacy mobile-open endpoints remain available without becoming the active Android workflow', () => {
test('F6.1 exposes mobile planning context and opens an inspection self-assigned to the current inspector', () => {
const controller = source('src/inspection-visits/inspection-visits.controller.ts');
const dto = source('src/inspection-visits/dto/open-mobile-inspection.dto.ts');
@@ -45,21 +45,23 @@ test('legacy mobile-open endpoints remain available without becoming the active
assert.match(controller, /@RequirePermissions\('inspections\.execute'\)/);
assert.match(controller, /assertMobileInspector\(principal\)/);
assert.match(controller, /leadInspectorUserId: principal\.userId/);
assert.match(controller, /plannedStartAt: new Date\(\)\.toISOString\(\)/);
assert.match(controller, /this\.lifecycle\.plan\(created\.id/);
assert.match(controller, /this\.lifecycle\.start\(created\.id/);
assert.match(dto, /operationalAreaId!: string/);
assert.match(dto, /operatorCompanyId!: string/);
});
test('Android lists inspections assigned from office and starts them only from the visit screen', () => {
test('F6.1 Android lets the inspector choose Area and current Operator and open the inspection now', () => {
const client = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/MobileInspectionOpen.kt');
const home = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/MobileHomeScreen.kt');
const visit = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernVisitRoot.kt');
const gate = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/LoginGate.kt');
assert.match(home, /Trabajo planificado desde oficina/);
assert.match(home, /inspecciones asignadas desde el tablero/);
assert.doesNotMatch(home, /Abrir inspección ahora/);
assert.doesNotMatch(home, /Operadora vigente/);
assert.match(home, /model\.openVisit\(visit\.id\)/);
assert.match(visit, /Iniciar inspección/);
assert.match(visit, /model\.startVisit\(\)/);
assert.match(client, /inspection-visits\/mobile\/planning-context\/areas/);
assert.match(client, /inspection-visits\/mobile\/open/);
assert.match(home, /Abrir inspección/);
assert.match(home, /Abrir inspección ahora/);
assert.match(home, /Operadora vigente/);
assert.match(home, /model\.openVisit\(opened\.id\)/);
assert.match(gate, /else -> MobileHomeScreen\(model\)/);
});
@@ -26,7 +26,7 @@ test('F6.1 inspection planning exposes Departamento → Área → Yacimiento exp
assert.doesNotMatch(createPage, /Área \/ Yacimiento/);
});
test('Authoritative WEB creation freezes Area and validates Company against selected Yacimiento', () => {
test('F6.1 WEB creation freezes Area and uses the selected Yacimiento as physical scope', () => {
const dto = source('src/inspection-visits/dto/create-inspection-visit.dto.ts');
const scope = source('src/inspection-visits/inspection-planning-scope.ts');
const create = source('src/inspection-visits/inspection-planning-create.service.ts');
@@ -37,18 +37,15 @@ test('Authoritative WEB creation freezes Area and validates Company against sele
assert.match(scope, /lower\(type\.code\)='yacimiento'/);
assert.match(create, /scopeAssetId,/);
assert.match(create, /operationalAreaId: dto\.operationalAreaId/);
assert.match(create, /yacimiento\.operator_company_id=\$3::uuid/);
assert.match(create, /yacimiento\.concession_type_id IS NOT NULL/);
assert.match(create, /INSPECTION_OPERATOR_NOT_RELATED_TO_YACIMIENTO/);
assert.match(create, /relation\.relation_role='OPERATOR'/);
assert.match(create, /relation\.valid_from <= \$3::timestamptz/);
assert.match(createPage, /scopeAssetId: yacimientoId/);
});
test('Authoritative WEB inspection creation uses Yacimiento context and not descendant Inventory snapshots', () => {
test('F6.1 WEB inspection creation does not use Inventory operator snapshots', () => {
const create = source('src/inspection-visits/inspection-planning-create.service.ts');
assert.match(create, /yacimiento\.operator_company_id/);
assert.match(create, /operatorSource: 'YACIMIENTO'/);
assert.doesNotMatch(create, /area_company_relations relation/);
assert.match(create, /area_company_relations relation/);
assert.doesNotMatch(create, /inventory\.operator_company_id/);
assert.doesNotMatch(create, /asset\.operator_company_id/);
});
@@ -28,7 +28,7 @@ test('F6.1 field Findings reject Inventory outside the frozen Yacimiento scope',
assert.doesNotMatch(source, /AS "insideArea"/);
});
test('F6.1 planning preserves Yacimiento and never validates descendant Inventory ownership', () => {
test('F6.1 planning preserves Yacimiento and never validates Inventory ownership from Company snapshot', () => {
const source = api('src/inspection-visits/inspection-visits.service.ts');
assert.match(source, /const nextScope = dto\.scopeAssetId === undefined \? visit\.scopeAssetId : dto\.scopeAssetId/);
@@ -38,6 +38,7 @@ test('F6.1 planning preserves Yacimiento and never validates descendant Inventor
assert.doesNotMatch(source, /visit\.scopeAssetId = nextAreaId/);
assert.doesNotMatch(source, /assertAssetsMatchContext/);
assert.doesNotMatch(source, /assertCurrentAssetsMatchContext/);
assert.doesNotMatch(source, /asset\.operator_company_id IS DISTINCT FROM/);
});
test('F6.1 creation selects actionable checklist Inventory automatically', () => {
@@ -48,33 +49,24 @@ test('F6.1 creation selects actionable checklist Inventory automatically', () =>
assert.match(source, /'AUTO_INCLUDED'/);
});
test('Authoritative operator choices are projected from Yacimientos of the selected Area', () => {
test('F6.1 operator choices are resolved at the planned timestamp', () => {
const hierarchy = api('src/inspection-visits/inspection-planning-hierarchy.service.ts');
const controller = api('src/inspection-visits/inspection-visits.controller.ts');
const createPage = web('src/pages/InspectionVisitCreateF61Page.tsx');
assert.match(hierarchy, /operatorsForArea\([\s\S]*at\?: string/);
assert.match(hierarchy, /FROM assets yacimiento/);
assert.match(hierarchy, /yacimiento\.parent_id=\$1::uuid/);
assert.match(hierarchy, /yacimiento\.operator_company_id/);
assert.match(hierarchy, /SELECT DISTINCT company\.id/);
assert.doesNotMatch(hierarchy, /FROM area_company_relations relation/);
assert.match(hierarchy, /relation\.valid_from <= \$2::timestamptz/);
assert.match(hierarchy, /relation\.valid_until > \$2::timestamptz/);
assert.match(controller, /@Query\('at'\) at\?: string/);
assert.match(createPage, /operators\?at=\$\{at\}/);
assert.match(createPage, /\[areaId, plannedStartAt\]/);
});
test('F6.1 WEB keeps Area and Yacimiento as separate concepts and freezes context after creation', () => {
test('F6.1 WEB keeps Area and Yacimiento as separate concepts when editing', () => {
const source = web('src/pages/InspectionVisitEditorF4Page.tsx');
assert.doesNotMatch(source, /<span>Área \/ Yacimiento<\/span>/);
assert.match(source, /<span>Área<\/span>/);
assert.match(source, /<span>Yacimiento<\/span>/);
assert.match(source, /<span>Operadora<\/span>/);
assert.match(source, /visit\.operationalArea\.name[\s\S]{0,160}readOnly/);
assert.match(source, /visit\.scopeAsset\.name[\s\S]{0,160}readOnly/);
assert.match(source, /visit\.operatorCompany\.name[\s\S]{0,160}readOnly/);
assert.doesNotMatch(source, /updateInspectionVisit\(id, \{[\s\S]{0,300}scopeAssetId:/);
assert.doesNotMatch(source, /updateInspectionVisit\(id, \{[\s\S]{0,300}operationalAreaId:/);
assert.doesNotMatch(source, /updateInspectionVisit\(id, \{[\s\S]{0,300}operatorCompanyId:/);
assert.match(source, /scopeAssetId: visit\?\.scopeAsset\?\.id \?\? null/);
});
@@ -7,24 +7,24 @@ function source(path: string): string {
return readFileSync(resolve(process.cwd(), path), 'utf8');
}
test('Authoritative model stores Company on Yacimiento and propagates it to physical descendants', () => {
const migration = source('src/database/migrations/1790106600000-authoritative-inventory-relationships.ts');
const structure = source('src/asset-master/inventory-structure.service.ts');
test('F6.1 preserves Inventory operator snapshots and changes operator only through Area relations', () => {
const relations = source('src/asset-master/asset-operational-relations.service.ts');
const migration = source('src/database/migrations/1790087250000-f5-operational-context-compatibility.ts');
assert.match(migration, /Yacimiento requiere Empresa relacionada/);
assert.match(migration, /NEW\.operational_area_id:=NEW\.parent_id/);
assert.match(migration, /NEW\.operator_company_id:=parent_company/);
assert.match(structure, /dto\.kind === 'YACIMIENTO'/);
assert.match(structure, /parent\?\.operatorCompanyId/);
assert.match(structure, /ensureCompatibilityProjection/);
assert.match(migration, /Runtime ownership and search MUST NOT depend on it/);
assert.match(migration, /La Empresa se cambia en la relación temporal del Área, no en el Inventario/);
assert.match(relations, /operatorSnapshotPreserved:true/);
assert.doesNotMatch(relations, /UPDATE assets asset[\s\S]*SET operator_company_id=/);
});
test('Authoritative Yacimiento relationships cannot be mutated through generic Inventory edits', () => {
test('F6.1 blocks operator mutation through generic Inventory edit DTOs', () => {
const updateDto = source('src/asset-master/dto/update-asset.dto.ts');
const contextDto = source('src/asset-master/dto/change-asset-context.dto.ts');
assert.match(updateDto, /@IsEmpty/);
assert.match(updateDto, /La Empresa relacionada se administra en el Yacimiento/);
assert.match(updateDto, /El Tipo de concesión se administra en el Yacimiento/);
for (const dto of [updateDto, contextDto]) {
assert.match(dto, /@IsEmpty/);
assert.match(dto, /La Operadora se administra en la relación temporal del Área, no en el Inventario/);
}
});
test('F6.1 rejects legacy Inventory searches that filter by operator snapshot', () => {
@@ -34,25 +34,28 @@ test('F6.1 rejects legacy Inventory searches that filter by operator snapshot',
for (const dto of [listDto, treeDto]) {
assert.match(dto, /operatorCompanyId\?: string/);
assert.match(dto, /@IsEmpty/);
assert.match(dto, /Filtrá la Operadora mediante la relación temporal del Área/);
}
});
test('Compatibility Area relation history remains available for legacy temporal queries', () => {
test('F6.1 resolves temporal Inventory operator from Area relation history', () => {
const temporal = source('src/asset-master/asset-temporal.service.ts');
const migration = source('src/database/migrations/1790106600000-authoritative-inventory-relationships.ts');
assert.match(temporal, /FROM area_company_relations relation/);
assert.match(migration, /Derivado automáticamente de Yacimientos del modelo autoritativo/);
assert.match(migration, /TRUNCATE TABLE area_company_relations CASCADE/);
assert.match(temporal, /relation\.valid_from <= \$2/);
assert.match(temporal, /relation\.valid_until IS NULL OR relation\.valid_until > \$2/);
assert.match(temporal, /operator_company\.id=operator_relation\.company_id/);
assert.doesNotMatch(temporal, /operator_company\.id=history\.operator_company_id/);
});
test('F6.1 verification planning can consume the Area compatibility projection', () => {
test('F6.1 verification planning uses Area relation truth instead of Inventory operator snapshot', () => {
const verification = source('src/inspection-verifications/inspection-verifications.service.ts');
const migration = source('src/database/migrations/1790106600000-authoritative-inventory-relationships.ts');
assert.match(verification, /FROM area_company_relations relation/);
assert.match(verification, /relation\.valid_from <= \$2::timestamptz/);
assert.match(verification, /VERIFICATION_OPERATOR_NOT_ACTIVE/);
assert.match(migration, /relation_role,source_document_id,valid_from,start_reason/);
assert.match(verification, /operatorSource: 'area_company_relations'/);
assert.doesNotMatch(verification, /company\.id = asset\.operator_company_id/);
});
test('F6.1 historical operational lists read Company and Area from the parent Inspection', () => {
@@ -65,37 +68,35 @@ test('F6.1 historical operational lists read Company and Area from the parent In
assert.doesNotMatch(acts, /context_asset\.operator_company_id/);
assert.match(findings, /company\.id = visit\.operator_company_id/);
assert.match(findings, /area\.id = visit\.operational_area_id/);
assert.doesNotMatch(findings, /company\.id = asset\.operator_company_id/);
assert.match(reports, /visit\.\$\{column\} = \$\{parameter\}::uuid/);
assert.match(reports, /company\.id=context_visit\.operator_company_id/);
assert.match(reports, /area\.id=context_visit\.operational_area_id/);
assert.doesNotMatch(reports, /context_asset\.operator_company_id/);
});
test('F6.1 WEB Inventory searches use physical scope and never the operator snapshot filter', () => {
test('F6.1 WEB Inventory searches use physical Area and never the operator snapshot filter', () => {
const inspectionEditor = source('../web-v2/src/pages/InspectionVisitEditorF4Page.tsx');
const preventiveApi = source('../web-v2/src/features/inspections/preventiveCandidatesApi.ts');
const preventiveService = source('src/inspection-visits/inspection-preventive-candidates.service.ts');
const fieldDiscoveries = source('../web-v2/src/pages/FieldDiscoveriesPage.tsx');
const inventoryMerge = source('../web-v2/src/lib/inventoryMergeApi.ts');
assert.match(inspectionEditor, /listInspectionPreventiveCandidates\(id, assetSearch\)/);
assert.match(preventiveApi, /inspection-visits\/\$\{visitId\}\/preventive-candidates/);
assert.match(preventiveService, /COALESCE\(visit\.scope_asset_id, visit\.operational_area_id\) AS "scopeAssetId"/);
assert.doesNotMatch(inspectionEditor, /listAssets\(\{[\s\S]{0,300}operatorCompanyId:/);
for (const page of [fieldDiscoveries, inventoryMerge]) {
for (const page of [inspectionEditor, fieldDiscoveries, inventoryMerge]) {
assert.match(page, /listAssets\(\{[\s\S]{0,300}operationalAreaId:/);
assert.doesNotMatch(page, /listAssets\(\{[\s\S]{0,300}operatorCompanyId:/);
}
assert.doesNotMatch(inventoryMerge, /source\.operatorCompany/);
});
test('WEB creation captures Company and concession only while creating a Yacimiento', () => {
const page = source('../web-v2/src/pages/InventoryCreatePage.tsx');
const structure = source('src/asset-master/inventory-structure.service.ts');
test('F6.1 WEB derives the creation snapshot from the active Area operator and removes it from Inventory edits', () => {
const editor = source('../web-v2/src/pages/AssetEditorPage.tsx');
const contextPanel = source('../web-v2/src/features/assets/AssetContextHistoryPanel.tsx');
assert.match(page, /requiresYacimientoContext = kind === 'YACIMIENTO'/);
assert.match(page, /Empresa relacionada/);
assert.match(page, /Tipo de concesión/);
assert.match(page, /operatorCompanyId:operatorCompanyId \|\| null/);
assert.match(page, /concessionTypeId:concessionTypeId \|\| null/);
assert.match(structure, /Empresa, Departamento, Área y Yacimiento no llevan clasificación técnica/);
assert.match(editor, /createAsset\(\{[\s\S]*operatorCompanyId: operatorCompanyId \|\| null/);
assert.doesNotMatch(editor, /updateAsset\(id, \{[^}]*operatorCompanyId/);
assert.doesNotMatch(editor, /parent\.operatorCompany/);
assert.match(editor, /listCompaniesForArea\(operationalAreaId\)\.then\(\(loadedCompanies\)/);
assert.match(editor, /loadedCompanies\.length === 1 \? \(loadedCompanies\[0\]\?\.id \?\? ''\) : ''/);
assert.match(editor, /Snapshot histórico de alta/);
assert.match(contextPanel, /Cambiar contexto físico/);
assert.doesNotMatch(contextPanel, /operatorCompanyId:/);
});
@@ -1,44 +0,0 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import test from 'node:test';
function source(path: string): string {
return readFileSync(resolve(process.cwd(), path), 'utf8');
}
test('F6.1 planning preloads the local start time and resolves Companies projected by Yacimientos', () => {
const page = source('../web-v2/src/pages/InspectionVisitCreateF61Page.tsx');
const hierarchy = source('src/inspection-visits/inspection-planning-hierarchy.service.ts');
assert.match(page, /function currentLocalDateTime\(\)/);
assert.match(page, /useState\(currentLocalDateTime\)/);
assert.match(page, /areas\/\$\{areaId\}\/operators\?at=\$\{at\}/);
assert.match(page, /response\.data\.length === 1/);
assert.match(page, /setOperatorId\(response\.data\[0\]\.id\)/);
assert.match(page, /Sin Operadora vigente para esa fecha/);
assert.match(hierarchy, /FROM assets yacimiento/);
assert.match(hierarchy, /yacimiento\.operator_company_id/);
assert.match(hierarchy, /SELECT DISTINCT company\.id/);
});
test('F6.1 WEB cancellation requires an explicit audited reason and is available while in progress', () => {
const editor = source('../web-v2/src/pages/InspectionVisitEditorF4Page.tsx');
const controller = source('src/inspection-visits/inspection-visits.controller.ts');
const dto = source('src/inspection-visits/dto/cancel-inspection-visit.dto.ts');
const lifecycle = source('src/inspection-visits/inspection-visit-lifecycle.service.ts');
assert.doesNotMatch(editor, /window\.prompt\('Indicá el motivo de cancelación/);
assert.match(editor, /showCancelForm/);
assert.match(editor, /cancelReason/);
assert.match(editor, /<textarea/);
assert.match(editor, /cancelReason\.trim\(\)\.length < 10/);
assert.match(editor, /maxLength=\{500\}/);
assert.match(editor, /'DRAFT', 'PLANNED', 'IN_PROGRESS'/);
assert.match(editor, /Confirmar cancelación/);
assert.match(controller, /@Post\(':id\/cancel'\)/);
assert.match(dto, /@MinLength\(10\)/);
assert.match(dto, /@MaxLength\(4000\)/);
assert.match(lifecycle, /\[InspectionVisitStatus\.DRAFT, InspectionVisitStatus\.PLANNED, InspectionVisitStatus\.IN_PROGRESS\]\.includes\(visit\.status\)/);
assert.match(lifecycle, /INSPECTION_WITH_SEALED_ACT_CANNOT_CANCEL/);
});
@@ -1,45 +0,0 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import test from 'node:test';
function source(path: string): string {
return readFileSync(resolve(process.cwd(), path), 'utf8');
}
test('F6.2 planning uses the shared CSRF-aware API requester', () => {
const page = source('../web-v2/src/pages/InspectionVisitCreateF61Page.tsx');
const api = source('../web-v2/src/lib/api.ts');
assert.match(page, /import \{ apiRequest \} from '\.\.\/lib\/api';/);
assert.doesNotMatch(page, /async function requestJson/);
assert.doesNotMatch(page, /fetch\(`\/api\/v3/);
assert.match(page, /apiRequest<CreatedInspection>\('\/inspection-visits'/);
assert.match(api, /headers\.set\('x-csrf-token', token\)/);
assert.match(api, /readCsrfCookie\(\)/);
});
test('F6.2 removes the duplicated global operational bar and hidden persisted filters', () => {
const layout = source('../web-v2/src/layout/AppLayout.tsx');
const context = source('../web-v2/src/context/OperationalContext.tsx');
assert.doesNotMatch(layout, /OperationalContextBar/);
assert.match(context, /localStorage\.removeItem\(LEGACY_STORAGE_KEY\)/);
assert.doesNotMatch(context, /localStorage\.setItem/);
assert.match(context, /useState\(''\)/);
});
test('F6.2 exposes provisional PDF data contracts for Actas and Informes', () => {
const projection = source('../web-v2/src/components/DocumentPdfProjection.tsx');
const actPage = source('../web-v2/src/pages/InspectionActEditorPage.tsx');
const reportPage = source('../web-v2/src/pages/ReportDetailPage.tsx');
assert.match(projection, /PDF PRELIMINAR · ESTRUCTURA PROYECTADA/);
assert.match(projection, /Contrato de datos estable, renderer reemplazable/);
assert.match(projection, /Identificación y contexto/);
assert.match(projection, /Firmas y manifestaciones/);
assert.match(projection, /GEDO e integridad/);
assert.match(projection, /Anexo final/);
assert.match(actPage, /<DocumentPdfProjection kind="act" \/>/);
assert.match(reportPage, /<DocumentPdfProjection kind="report" \/>/);
});
@@ -1,40 +0,0 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import test from 'node:test';
const api = (path: string) => readFileSync(resolve(process.cwd(), path), 'utf8');
const web = (path: string) => readFileSync(resolve(process.cwd(), '..', 'web-v2', path), 'utf8');
test('F6.2 protects the frozen Inspection context at database level', () => {
const migration = api('src/database/migrations/1790098200000-f6-2-freeze-inspection-context.ts');
assert.match(migration, /prevent_inspection_context_mutation/);
assert.match(migration, /NEW\.operational_area_id IS DISTINCT FROM OLD\.operational_area_id/);
assert.match(migration, /NEW\.scope_asset_id IS DISTINCT FROM OLD\.scope_asset_id/);
assert.match(migration, /NEW\.operator_company_id IS DISTINCT FROM OLD\.operator_company_id/);
assert.match(migration, /BEFORE UPDATE OF operational_area_id, scope_asset_id, operator_company_id/);
});
test('F6.2 preventive candidates are descendants of the frozen Yacimiento only', () => {
const service = api('src/inspection-visits/inspection-preventive-candidates.service.ts');
const controller = api('src/inspection-visits/inspection-preventive-candidates.controller.ts');
assert.match(service, /COALESCE\(visit\.scope_asset_id, visit\.operational_area_id\) AS "scopeAssetId"/);
assert.match(service, /WITH RECURSIVE scope_tree AS/);
assert.match(service, /child\.parent_id/);
assert.match(service, /asset\.operational_area_id = \$3::uuid/);
assert.match(service, /lower\(asset_type\.code\) IN \('instalacion', 'subinstalacion'\)/);
assert.match(service, /NOT EXISTS \([\s\S]*inspection_visit_assets linked/);
assert.match(controller, /:id\/preventive-candidates/);
});
test('F6.2 WEB uses the server-scoped preventive selector instead of the mutable Area form', () => {
const page = web('src/pages/InspectionVisitEditorF4Page.tsx');
const apiClient = web('src/features/inspections/preventiveCandidatesApi.ts');
assert.match(page, /listInspectionPreventiveCandidates\(id, assetSearch\)/);
assert.doesNotMatch(page, /listAssets\(\{[\s\S]{0,220}operationalAreaId: form\.operationalAreaId/);
assert.match(page, /Sólo se muestran Instalaciones y Subinstalaciones pertenecientes al Yacimiento/);
assert.match(apiClient, /inspection-visits\/\$\{visitId\}\/preventive-candidates/);
});
@@ -1,49 +0,0 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import test from 'node:test';
function source(path: string) {
return readFileSync(resolve(process.cwd(), path), 'utf8');
}
test('F6.3 field types start at the frozen Yacimiento instead of the Area', () => {
const structure = source('src/inspection-visits/f3-field-inventory-structure.service.ts');
assert.match(structure, /parentId \?\? base\.parent\.id/);
assert.doesNotMatch(structure, /parentId \?\? base\.context\.area\.id/);
});
test('F6.3 an Acta can start empty and receive Inventory when Hallazgos are added', () => {
const dto = source('src/inspection-acts/dto/create-inspection-act.dto.ts');
const service = source('src/inspection-acts/inspection-acts.service.ts');
const mobile = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/MobileActs.kt');
assert.doesNotMatch(dto, /ArrayMinSize\(1\)/);
assert.match(service, /if \(assetIds\.length === 0\) return/);
assert.match(mobile, /assetIds = listOfNotNull\(assetId\)/);
});
test('F6.3 every Installation and Subinstallation receives the common field card', () => {
const migration = source('src/database/migrations/1790099100000-f6-3-mobile-field-common-attributes.ts');
for (const label of ['Marca', 'Modelo', 'Capacidad', 'Número de serie', 'Función']) {
assert.match(migration, new RegExp(label));
}
assert.match(migration, /'instalacion','subinstalacion'/);
assert.match(migration, /'TEXT'::asset_attribute_data_type/);
assert.match(migration, /false, true/);
});
test('F6.3 Android follows Inspección → Acta → Hallazgo → Inventario', () => {
const root = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/F3VisitRoot.kt');
const acts = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/MobileActsScreen.kt');
const vm = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/MainViewModel.kt');
assert.match(root, /model\.startVisit\(\); onActs\(\)/);
assert.match(root, /Text\("Nuevo Hallazgo"/);
assert.match(root, /Buscar instalación o subinstalación/);
assert.match(acts, /Text\("\+ Agregar Hallazgo"\)/);
assert.match(acts, /model\.createAct\(newActUrgency\)/);
assert.match(vm, /fun createAct\(urgency: String = "NON_URGENT"\)/);
assert.match(vm, /repository\.fieldInventory\(currentVisit\.id, search, parentId\)/);
});
@@ -1,43 +0,0 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import test from 'node:test';
function apiFile(path: string): string {
return readFileSync(resolve(process.cwd(), path), 'utf8');
}
function repoFile(path: string): string {
return readFileSync(resolve(process.cwd(), '..', path), 'utf8');
}
test('F6.4 Android Installation picker searches live and selects a concrete parent', () => {
const screen = repoFile('android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernVisitRoot.kt');
assert.match(screen, /LaunchedEffect\(modeName, parentSearch\)/);
assert.match(screen, /delay\(280\)/);
assert.match(screen, /model\.searchInventory\(parentSearch\.trim\(\), visit\.scopeAsset\?\.id\)/);
assert.match(screen, /ElevatedCard\(onClick = \{ chooseParent\(item\) \}/);
assert.match(screen, /model\.loadFieldTypes\(item\.id\)/);
});
test('F6.4 Android field shell uses the modern theme and workspace', () => {
const gate = repoFile('android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/LoginGate.kt');
const theme = repoFile('android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/DhTheme.kt');
assert.match(gate, /DhTheme \{/);
assert.match(gate, /model\.visit != null -> ModernVisitRoot\(model\)/);
assert.match(theme, /RoundedCornerShape\(16\.dp\)/);
});
test('F6.4 Android Act list uses a mobile read model independent from office reports', () => {
const android = repoFile('android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/MobileActs.kt');
const controller = apiFile('src/inspection-acts/mobile-inspection-acts.controller.ts');
const service = apiFile('src/inspection-acts/mobile-inspection-acts.service.ts');
assert.match(android, /inspection-visits\/\{visitId\}\/acts\/mobile/);
assert.match(controller, /inspection-visits\/:visitId\/acts\/mobile/);
assert.match(service, /FROM inspection_acts act/);
assert.doesNotMatch(service, /inspection_reports/);
assert.doesNotMatch(service, /inspection_document/);
});
@@ -1,50 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { DataSource } from 'typeorm';
import { FieldBriefingService } from '../../src/act-administration/field-briefing.service';
test('field briefing convierte el timestamptz de pg a YYYY-MM-DD antes del cast SQL', async () => {
const calls: Array<{ sql: string; params: unknown[] }> = [];
let queryNumber = 0;
const dataSource = {
query: async (sql: string, params: unknown[]) => {
calls.push({ sql, params });
queryNumber += 1;
if (queryNumber === 1) {
return [{
id: '11111111-1111-4111-8111-111111111111',
code: 'INSP-00002-17-09-26',
status: 'DRAFT',
plannedStartAt: new Date('2026-09-17T23:42:00.000Z'),
areaId: '22222222-2222-4222-8222-222222222222',
companyId: '33333333-3333-4333-8333-333333333333',
areaCode: 'PRES-AREA-TEST',
areaName: 'Barrancas',
companyCode: 'PRES-ORG-TEST',
companyName: 'Petróleos Sudamericanos Energy S.A.',
}];
}
return [];
},
} as unknown as DataSource;
const result = await new FieldBriefingService(dataSource).forVisit(
'11111111-1111-4111-8111-111111111111',
);
assert.equal(calls.length, 2);
assert.equal(calls[1]?.params[3], '2026-09-17');
assert.equal(result.plannedOn, '2026-09-17');
assert.deepEqual(result.summary, {
acts: 0,
findings: 0,
inventoryItems: 0,
responseOverdue: 0,
commitmentOverdue: 0,
verificationPending: 0,
});
assert.deepEqual(result.acts, []);
});
@@ -7,7 +7,6 @@ import { ChangeInspectionVisitStatusDto } from '../../src/inspection-visits/dto/
import { CreateInspectionVisitDto } from '../../src/inspection-visits/dto/create-inspection-visit.dto';
import { ReplaceInspectionVisitAssetsDto } from '../../src/inspection-visits/dto/replace-inspection-visit-assets.dto';
import { ReplaceInspectionVisitTeamDto } from '../../src/inspection-visits/dto/replace-inspection-visit-team.dto';
import { UpdateInspectionVisitDto } from '../../src/inspection-visits/dto/update-inspection-visit.dto';
const AREA_ID = '16e54e65-60cf-4739-b0d1-ccdd904fbfd5';
const COMPANY_ID = 'ce96211d-ac15-4421-8868-4185323aff61';
@@ -26,25 +25,6 @@ test('visit DTO accepts only the minimum planning data required for quick creati
assert.equal(dto.leadInspectorUserId, USER_ID);
});
test('visit update DTO keeps Area, Yacimiento and Operadora immutable after creation', async () => {
const contextAttempt = plainToInstance(UpdateInspectionVisitDto, {
operationalAreaId: AREA_ID,
scopeAssetId: AREA_ID,
operatorCompanyId: COMPANY_ID,
});
const errors = await validate(contextAttempt);
assert.equal(errors.some((error) => error.property === 'operationalAreaId'), true);
assert.equal(errors.some((error) => error.property === 'scopeAssetId'), true);
assert.equal(errors.some((error) => error.property === 'operatorCompanyId'), true);
const ordinaryUpdate = plainToInstance(UpdateInspectionVisitDto, {
objective: 'Control preventivo programado',
plannedStartAt: '2026-09-17T12:00:00.000Z',
instructions: 'Coordinar ingreso con la Operadora.',
});
assert.deepEqual(await validate(ordinaryUpdate), []);
});
test('asset and team DTOs reject duplicate references', async () => {
const assets = Object.assign(new ReplaceInspectionVisitAssetsDto(), {
assetIds: [AREA_ID, AREA_ID],
+62 -90
View File
@@ -1,124 +1,96 @@
# Modelo documental de inspecciones
Este documento fija el modelo funcional vigente para Inspecciones, Actas,
Hallazgos e Informes. La estructura visual definitiva de los documentos todavía
puede cambiar cuando Hidrocarburos entregue las plantillas oficiales; el contrato
de datos, la trazabilidad y las reglas de inmutabilidad deben permanecer estables.
Este documento fija el modelo funcional validado a partir del Informe Técnico
Nº 124/2026, el acta manuscrita adjunta y la planilla de hallazgos entregada.
## Canales de operación
| Función | APK móvil | Dashboard web |
|---|---:|---:|
| Planificar Inspección, alcance y equipo | Consulta | Administra |
| Administrar Inventarios y catálogo de Hallazgos | Consulta | Administra |
| Iniciar y ejecutar la Inspección | Inspector | No |
| Crear Actas y registrar Hallazgos de campo | Inspector | Sólo consulta |
| Planificar visita, activos y equipo | Consulta | Administra |
| Administrar activos y catálogo de hallazgos | Consulta | Administra |
| Iniciar y ejecutar la visita | Inspector | No |
| Crear o modificar el acta y sus hallazgos | Inspector | Sólo consulta |
| Incorporar evidencia de campo y GPS | Inspector | Sólo consulta |
| Identificar responsable, firmar y sellar Acta | Inspector | Sólo consulta |
| Preparar y revisar el INF | Consulta | Administra |
| Registrar oficialización GEDO/IF y PDF oficial | Consulta | Administra |
| Registrar respuesta empresarial y seguimientos | Consulta | Administra |
| Consultar Actas, Hallazgos e Informes sincronizados | Sí | Sí |
| Identificar responsable, firmar y cerrar | Inspector | Sólo consulta |
| Registrar respuesta empresarial, PDF y próximo control | Consulta | Administra |
| Consultar acta, hallazgos e informe sincronizados | Sí | Sí |
La API aplica esta separación además de la interfaz. Las escrituras operativas de
campo se realizan desde la aplicación móvil con las autorizaciones correspondientes;
el dashboard administra planificación, consulta y circuito documental posterior.
La API aplica esta separación además de la interfaz: las escrituras operativas
requieren autenticación bearer de la aplicación móvil y el rol `inspector`.
Una sesión web no puede eludir la restricción llamando directamente al endpoint.
## Cardinalidades vigentes
## Cardinalidades
| Origen | Relación | Destino |
|---|---:|---|
| Inspección o visita | 1 a muchos | Actas de inspección |
| Inspección o visita | 1 a 1 | Acta de inspección |
| Acta | 1 a muchos | Hallazgos |
| Hallazgo | muchos a 1 | Elemento de Inventario |
| Hallazgo | muchos a 1 | Ítem del catálogo configurable, o propuesta OTROS |
| Hallazgo | 1 a muchos | Evidencias y seguimientos |
| Hallazgo | muchos a 1 | Activo o equipo del Maestro |
| Hallazgo | muchos a 1 | Ítem del catálogo configurable |
| Hallazgo | 1 a muchos | Evidencias, principalmente fotografías |
| Acta | 1 a muchos | Firmas de inspectores |
| Acta | 0 o 1 | Firma/manifestación del responsable de empresa |
| Acta sellada | 0 o 1 | Informe técnico (INF) |
| Informe técnico | 1 a 1 | Acta fuente |
| Informe técnico | incorpora | Acta, Hallazgos, Inventarios y evidencias congelados |
Una Inspección puede, por lo tanto, contener varias Actas y generar varios INF,
pero cada INF conserva una única Acta fuente. No se mezcla el contenido de dos
Actas dentro de un mismo Informe.
| Acta | 1 a 1 | Firma del responsable que atendió la visita |
| Inspección o visita | 1 a 1 | Informe técnico |
| Informe técnico | incorpora | Acta, hallazgos y evidencias congelados |
## Acta digital
El Acta debe poder reunir como mínimo:
El acta debe reunir como mínimo:
- número y año oficial del Acta;
- Inspección vinculada, fecha y hora;
- Departamento, Área, Yacimiento, Operadora y ubicación de la actuación;
- objeto, alcance y descripción de lo actuado;
- Instalaciones y Subinstalaciones inspeccionadas;
- todos los Hallazgos del Acta, con descripción, Inventario afectado, base legal,
reincidencia, gravedad y plazos que correspondan;
- número anual oficial, fecha, hora y ubicación;
- empresa, área, yacimiento o instalación inspeccionada;
- objeto y resultado de la inspección;
- todos los hallazgos de la visita y sus plazos de corrección;
- inspectores intervinientes;
- identidad, DNI, cargo y correo del responsable de la empresa que recibió al
equipo, cuando corresponda;
- firma de inspectores y manifestación/firma del responsable de empresa, incluida
conformidad, disidencia, negativa o ausencia según el flujo aplicable;
- versión inmutable, hash, sello de tiempo y mecanismo de verificación documental;
- referencias a fotografías/evidencias cuando el formulario oficial determine que
deben formar parte del documento.
equipo;
- firmas manuscritas capturadas en pantalla de inspectores y responsable;
- versión inmutable, hash, sello de tiempo y token de verificación.
Al sellarse, el Acta queda inmutable. Los cambios posteriores pertenecen al
seguimiento y no reescriben el contenido constatado. El sistema conserva la
instantánea y hashes necesarios para que el PDF se pueda regenerar o sustituir su
renderer sin alterar la evidencia fuente.
Al finalizar la visita, el acta se cierra y ya no admite edición. Se genera el
PDF verificable y se envía al correo del responsable de la empresa y a una
dirección interna de Hidrocarburos definida por configuración. Cada intento y
resultado de entrega debe quedar auditado.
Si se requiere corregir un acta cerrada, se emitirá una rectificativa vinculada
sin reemplazar ni borrar el documento original.
## Hallazgos
El catálogo de Hallazgos es administrable, importable y versionado. Cada Hallazgo
operativo conserva una instantánea de su descripción y base legal para que cambios
posteriores del catálogo no modifiquen documentos ya emitidos.
La planilla entregada contiene 170 modelos de hallazgo agrupados en 19
categorías de equipos, con descripción, normativa y glosario. Se implementará
como catálogo administrable, importable y versionado. No se copiarán esas
opciones como constantes en la interfaz.
Cada Hallazgo nace abierto. Se distinguen el plazo exigido, la respuesta y fecha
comprometida por la empresa, la fecha de próximo control y las verificaciones
posteriores. La respuesta empresarial no cierra por sí sola un Hallazgo: el cierre
surge del flujo de verificación autorizado.
Cada hallazgo operativo conservará una instantánea de su descripción y base
legal para que posteriores cambios del catálogo no alteren documentos ya
emitidos. También podrá registrar estado, observación específica, activo,
ubicación/GPS, plazo y evidencias.
## Informe técnico (INF)
Cada hallazgo nace abierto. Se distinguen el plazo exigido, la respuesta y
fecha comprometida por la empresa, y la fecha de próximo control fijada por el
organismo. La respuesta empresarial no cierra el hallazgo: sólo una verificación
posterior autorizada puede hacerlo.
El INF nace desde una Acta sellada y tiene numeración propia. Mientras está en
preparación dispone de un Word editable para el desarrollo técnico; modificar ese
Word o los campos narrativos no modifica el Acta ni sus Hallazgos fuente.
## Informe técnico
El contrato proyectado del Informe contempla:
El informe se genera después del cierre del acta, con numeración anual global
independiente. Siguiendo el modelo entregado, contempla:
- número/año del INF y Acta referenciada;
- Inspección, Empresa, Departamento, Área, Yacimiento y autores;
- objetivos y antecedentes;
- título, número, acta referenciada, empresa, área/yacimiento y autores;
- objetivos;
- antecedentes;
- marco legal;
- resumen ejecutivo;
- descripción y análisis técnico de la inspección;
- Inventarios inspeccionados;
- detalle de los Hallazgos, reincidencias, gravedad y plazos;
- registro fotográfico/evidencias seleccionadas;
- conclusiones y recomendaciones;
- trazabilidad e integridad de la fuente;
- Acta digital completa como anexo final.
- descripción de la inspección y de todos los hallazgos;
- registro fotográfico;
- conclusión;
- acta digital completa como anexo final.
Cuando GEDO devuelve el identificador IF y su PDF oficial, ambos se registran de
forma inmutable junto con la fecha de oficialización y el hash del archivo. La
carga del IF/PDF oficial no se interpreta por sí sola como notificación a la
empresa ni inicia plazos que dependan de una notificación posterior.
El informe congela las versiones exactas del acta, hallazgos, activos,
evidencias y normativa que incorpora.
## Plantillas PDF provisionales
Hasta recibir los formularios oficiales, la implementación se divide en dos capas:
1. **Contrato de datos estable.** Define qué información debe estar disponible para
Actas e Informes, de dónde proviene, qué versión se congela y qué hashes prueban
su integridad.
2. **Renderer reemplazable.** Encabezado, logos, tipografías, márgenes, cuadros,
orden visual, QR y anexos podrán adaptarse al modelo institucional definitivo
sin modificar el dominio ni volver a diseñar el circuito documental.
El dashboard muestra una sección **PDF preliminar · estructura proyectada** tanto
en la ficha del Acta como en la del Informe. Esa sección no pretende reemplazar el
formulario oficial: sirve para comprobar ahora que el sistema está capturando toda
la información que después deberá volcarse al documento.
La especificación detallada se mantiene en `docs/PDF_PROVISIONAL_STRUCTURE.md`.
La solicitud de creación del informe corresponde al flujo del inspector en la
APK después de cerrar la visita. El backend numera, compone y sella el PDF; el
dashboard sólo lo consulta, descarga y administra su seguimiento documental.
-184
View File
@@ -1,184 +0,0 @@
# Estructura provisional de PDFs · PREVIEW-1
Estado: **provisional hasta recepción de formularios oficiales**.
El objetivo de este documento no es fijar todavía la estética institucional. Fija
el conjunto de datos que DH Inspección debe ser capaz de colocar en los PDFs de
Actas e Informes y separa ese contrato del renderer visual.
## Principio de implementación
El dominio documental no debe depender de coordenadas, cuadros o textos impresos
de una plantilla determinada. Los datos se congelan primero en snapshots
versionados; luego un renderer transforma esa fuente a PDF. Cuando llegue el
formulario oficial se reemplaza o adapta el renderer, no la lógica de negocio.
Se consideran **estables**: identidad del documento, vínculos entre entidades,
contenido constatado, Hallazgos, firmas/manifestaciones, fechas, versiones, hashes
y trazabilidad. Se consideran **reemplazables**: logo, membrete, encabezado,
footer, tipografía, colores, tamaños, posiciones, cantidad de columnas, QR y forma
de los anexos.
## PDF de Acta
### 1. Cabecera institucional
Pendiente de modelo oficial. Debe reservar espacio para organismo, dependencia,
logo/membrete, denominación del documento y numeración.
### 2. Identificación
- número y año del Acta;
- código interno;
- código de Inspección/visita;
- fecha y hora de actuación;
- estado documental y versión.
### 3. Contexto territorial y operativo
- Departamento;
- Área;
- Yacimiento;
- Empresa/Operadora vigente;
- ubicación/GPS cuando corresponda;
- Instalación y Subinstalación relacionadas cuando el Acta tenga ese alcance.
### 4. Personas intervinientes
- Inspectores actuantes;
- responsable de la empresa;
- DNI/identificación;
- cargo o función;
- correo/dato de contacto que exija el formulario.
### 5. Objeto y descripción de lo actuado
- objeto o motivo;
- resumen/constatación;
- observaciones;
- Inventarios inspeccionados con código, nombre, tipo y ruta jerárquica.
### 6. Hallazgos
Por cada Hallazgo:
- código;
- Inventario asociado;
- ítem de catálogo u origen OTROS;
- título/denominación;
- descripción constatada;
- base legal congelada;
- reincidencia y referencia al antecedente, si existe;
- gravedad cuando aplique;
- plazo y tipo de días cuando corresponda;
- referencias a evidencia.
### 7. Firmas y manifestaciones
- firma de cada Inspector interviniente;
- firma/manifestación del responsable de empresa;
- conformidad o disidencia;
- texto de disidencia;
- negativa o ausencia con motivo cuando aplique;
- fecha/hora de cada constancia.
### 8. Integridad
- hash del contenido bloqueado;
- hash final del Acta sellada;
- sello de tiempo;
- versión del esquema;
- espacio para token/QR de verificación si el modelo institucional lo utiliza.
### 9. Anexo de evidencias
El sistema debe estar preparado para incorporar fotografías/evidencias, pero su
inclusión dentro del PDF principal queda pendiente de la plantilla oficial.
## Informe técnico · INF
Cada INF tiene **una sola Acta fuente**. Una Inspección puede contener múltiples
Actas y, por lo tanto, múltiples INF independientes.
### 1. Cabecera e identificación
- número/año y código del INF;
- Acta fuente;
- Inspección;
- fecha de generación;
- autores/Inspector responsable;
- Empresa/Operadora;
- Departamento, Área y Yacimiento.
### 2. Objetivos
Texto editable durante la preparación, si la plantilla oficial lo requiere como
campo separado.
### 3. Antecedentes
Antecedentes relevantes para comprender la inspección y el contexto técnico.
### 4. Marco legal
Normativa aplicable y bases legales congeladas con los Hallazgos del Acta fuente.
### 5. Resumen ejecutivo
Campo narrativo editable en estado de preparación.
### 6. Descripción y análisis técnico
Desarrollo técnico editable que no modifica el contenido del Acta ni de sus
Hallazgos.
### 7. Inventarios inspeccionados
Tabla o listado con código, nombre, tipo y ruta territorial/técnica.
### 8. Hallazgos
Detalle de los Hallazgos del Acta fuente, con descripción, base legal,
reincidencia, gravedad, plazos y vínculo con el Inventario.
### 9. Registro fotográfico
Fotografías/evidencias seleccionadas, identificando Hallazgo/Inventario, fecha y
metadatos disponibles. La maquetación se definirá con el modelo oficial.
### 10. Conclusiones y recomendaciones
Bloque técnico editable durante la preparación del INF.
### 11. GEDO
Al oficializar:
- identificador IF;
- fecha/hora de oficialización;
- nombre del PDF oficial;
- SHA-256 del PDF oficial.
La oficialización no equivale automáticamente a notificación a la empresa.
### 12. Integridad y anexo
- hash del snapshot fuente del INF;
- hash del cierre del Acta;
- versión de Acta utilizada;
- Acta fuente completa como anexo final.
## Reemplazo por la plantilla oficial
Cuando se reciba el Acta o Informe oficial en PDF/Word se deberá:
1. inventariar cada campo del formulario;
2. mapearlo contra este contrato;
3. identificar únicamente los datos faltantes;
4. añadir esos datos al dominio sólo si son información real y no mero diseño;
5. construir el renderer definitivo;
6. comparar visualmente una muestra contra el original;
7. conservar el renderer PREVIEW-1 sólo para pruebas históricas si resultara útil.
No se deben alterar Actas ya selladas para acomodar una nueva plantilla. Los
nuevos renderers deben poder representar snapshots históricos de forma trazable.
+5 -5
View File
@@ -14,10 +14,10 @@ import { AccessDeniedPage, NotFoundPage } from '../pages/SystemPages';
import { UserDetailPage } from '../pages/UserDetailPage';
import { UsersPage } from '../pages/UsersPage';
import { AssetsPage } from '../pages/AssetsPage';
import { InventoryDetailPage } from '../pages/InventoryDetailPage';
import { AssetEditorPage } from '../pages/AssetEditorPage';
import { InventoryCreatePage } from '../pages/InventoryCreatePage';
import { FieldDiscoveriesPage } from '../pages/FieldDiscoveriesPage';
import { AuthoritativeInventoryConfigPage } from '../pages/AuthoritativeInventoryConfigPage';
import { AssetTypesPage } from '../pages/AssetTypesPage';
import { HistoryPage } from '../pages/HistoryPage';
import { TemporalAssetsPage } from '../pages/TemporalAssetsPage';
import { InspectionVisitsPage } from '../pages/InspectionVisitsPage';
@@ -49,9 +49,9 @@ export function App() {
<Route element={<PermissionRoute permission="assets.read" />}><Route path="/mapa" element={<MapPage />} /></Route>
<Route element={<PermissionRoute permission="assets.read" />}>
<Route path="/inventarios" element={<AssetsPage />} />
<Route path="/inventarios/:id" element={<InventoryDetailPage />} />
<Route path="/inventarios/:id" element={<AssetEditorPage />} />
<Route path="/activos" element={<Navigate to="/inventarios" replace />} />
<Route path="/activos/:id" element={<InventoryDetailPage />} />
<Route path="/activos/:id" element={<AssetEditorPage />} />
</Route>
<Route element={<PermissionRoute permission="assets.change_status" />}><Route path="/inventarios/revision-campo" element={<FieldDiscoveriesPage />} /></Route>
<Route element={<PermissionRoute permission="assets.create" />}><Route path="/inventarios/nuevo" element={<InventoryCreatePage />} /><Route path="/activos/nuevo" element={<Navigate to="/inventarios/nuevo" replace />} /></Route>
@@ -79,7 +79,7 @@ export function App() {
<Route element={<PermissionRoute permission="users.create" />}><Route path="/admin/users/new" element={<NewUserPage />} /></Route>
<Route element={<PermissionRoute permission="roles.read" />}><Route path="/admin/roles" element={<RolesPage />} /></Route>
<Route element={<PermissionRoute permission="audit.read" />}><Route path="/admin/audit" element={<AuditPage />} /></Route>
<Route element={<PermissionRoute permission="asset_types.read" />}><Route path="/admin/asset-types" element={<AuthoritativeInventoryConfigPage />} /></Route>
<Route element={<PermissionRoute permission="asset_types.read" />}><Route path="/admin/asset-types" element={<AssetTypesPage />} /></Route>
<Route element={<PermissionRoute permission="finding_catalog.manage" />}><Route path="/admin/finding-catalog" element={<FindingCatalogPage />} /></Route>
<Route element={<PermissionRoute permission="document_delivery.read" />}><Route path="/admin/document-delivery" element={<DocumentDeliveryPage />} /></Route>
<Route path="/sin-acceso" element={<AccessDeniedPage />} />
-26
View File
@@ -1,26 +0,0 @@
import type { AuthUser } from '../lib/api';
function normalizeRole(value: string) {
return value
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, ' ')
.trim();
}
const ADMIN_ROLES = new Set([
'admin',
'administrator',
'administrador',
'superadmin',
'super admin',
'superadministrator',
'super administrator',
'superadministrador',
'super administrador',
]);
export function hasAdministratorRole(user: AuthUser | null) {
return Boolean(user?.roles.some((role) => ADMIN_ROLES.has(normalizeRole(role))));
}
@@ -1,109 +0,0 @@
import { Icon } from './Icon';
type ProjectionKind = 'act' | 'report';
interface ProjectionSection {
title: string;
description: string;
}
const actSections: ProjectionSection[] = [
{
title: 'Identificación y contexto',
description: 'Número y año de Acta, Inspección vinculada, fecha y hora, Departamento, Área, Yacimiento, Operadora y ubicación de la actuación.',
},
{
title: 'Intervinientes',
description: 'Inspectores actuantes y responsable de la empresa: nombre, DNI, cargo, correo y demás datos de identificación que exija el modelo oficial.',
},
{
title: 'Alcance y constatación',
description: 'Objeto de la actuación, descripción de lo actuado, observaciones e Inventarios inspeccionados con su ruta Instalación / Subinstalación.',
},
{
title: 'Hallazgos',
description: 'Código, Inventario afectado, descripción, base legal, reincidencia, gravedad y plazos aplicables, conservando la instantánea emitida en campo.',
},
{
title: 'Firmas y manifestaciones',
description: 'Firmas de inspectores y responsable de empresa, conformidad o disidencia, manifestación textual, negativa/ausencia si correspondiera y marcas de tiempo.',
},
{
title: 'Integridad y verificación',
description: 'Versión sellada, hash SHA-256, sello de tiempo y espacio reservado para token o QR de verificación documental.',
},
{
title: 'Anexos',
description: 'Registro fotográfico y evidencias sólo si el formulario oficial exige incorporarlos al Acta; el modelo queda preparado para hacerlo sin alterar el dato fuente.',
},
];
const reportSections: ProjectionSection[] = [
{
title: 'Identificación y contexto',
description: 'Número y año del INF, Acta fuente, Inspección, Empresa, Departamento, Área, Yacimiento, fechas y autores/intervinientes.',
},
{
title: 'Objetivos y antecedentes',
description: 'Motivo de la inspección, antecedentes relevantes y alcance técnico del Informe.',
},
{
title: 'Marco legal',
description: 'Normativa y bases legales aplicables, incluyendo las referencias congeladas de los Hallazgos del Acta fuente.',
},
{
title: 'Resumen ejecutivo y desarrollo técnico',
description: 'Resumen ejecutivo, descripción de la inspección, análisis técnico y consideraciones editables durante la preparación del INF.',
},
{
title: 'Inventarios y Hallazgos',
description: 'Inventarios inspeccionados y detalle de Hallazgos con descripción, reincidencia, gravedad, plazos y trazabilidad con el Acta.',
},
{
title: 'Registro fotográfico',
description: 'Fotografías y evidencias seleccionadas, con referencia al Inventario/Hallazgo, fecha y metadatos disponibles.',
},
{
title: 'Conclusiones',
description: 'Conclusión técnica, recomendaciones y texto final sujeto a revisión antes de la oficialización.',
},
{
title: 'GEDO e integridad',
description: 'Identificador IF, fecha de oficialización, hash del PDF oficial y hash de la instantánea congelada que originó el Informe.',
},
{
title: 'Anexo final',
description: 'Acta fuente completa e inmutable como anexo del Informe, manteniendo la relación exacta INF → Acta.',
},
];
export function DocumentPdfProjection({ kind }: { kind: ProjectionKind }) {
const isAct = kind === 'act';
const sections = isAct ? actSections : reportSections;
return <section className="panel document-pdf-projection">
<div className="panel-heading">
<div>
<span className="eyebrow">PDF PRELIMINAR · ESTRUCTURA PROYECTADA</span>
<h2>{isAct ? 'Contenido previsto del Acta' : 'Contenido previsto del Informe'}</h2>
<p className="section-copy">El diseño institucional definitivo todavía no fue recibido. Esta vista fija qué información debe poder alimentar el documento, sin comprometer encabezados, tipografías, logos ni distribución final.</p>
</div>
<span className="status-badge pending">Plantilla oficial pendiente</span>
</div>
<div className="temporal-notice">
<Icon name="clipboard" />
<p><strong>Contrato de datos estable, renderer reemplazable.</strong> Cuando llegue el PDF/Word oficial se adapta la composición visual sobre estos mismos datos, sin rehacer el circuito de Inspecciones, Actas, Hallazgos ni trazabilidad.</p>
</div>
<div className="dossier-link-list">
{sections.map((section, index) => <div key={section.title}>
<div>
<strong>{index + 1}. {section.title}</strong>
<small>{section.description}</small>
</div>
<span>Previsto</span>
</div>)}
</div>
</section>;
}
+20 -7
View File
@@ -10,18 +10,31 @@ interface OperationalContextValue {
clearContext: () => void;
}
const LEGACY_STORAGE_KEY = 'dhv2.operational-context';
const STORAGE_KEY = 'dhv2.operational-context';
const OperationalContext = createContext<OperationalContextValue | null>(null);
function readStoredContext(): { areaId: string; companyId: string } {
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return { areaId: '', companyId: '' };
const parsed = JSON.parse(raw) as { areaId?: unknown; companyId?: unknown };
return {
areaId: typeof parsed.areaId === 'string' ? parsed.areaId : '',
companyId: typeof parsed.companyId === 'string' ? parsed.companyId : '',
};
} catch {
return { areaId: '', companyId: '' };
}
}
export function OperationalContextProvider({ children }: PropsWithChildren) {
const [areaId, setAreaState] = useState('');
const [companyId, setCompanyState] = useState('');
const initial = useMemo(readStoredContext, []);
const [areaId, setAreaState] = useState(initial.areaId);
const [companyId, setCompanyState] = useState(initial.companyId);
useEffect(() => {
// F6.2 retiró la barra global Área/Operadora. Un contexto persistido sin
// controles visibles podía seguir filtrando pantallas de forma inesperada.
window.localStorage.removeItem(LEGACY_STORAGE_KEY);
}, []);
window.localStorage.setItem(STORAGE_KEY, JSON.stringify({ areaId, companyId }));
}, [areaId, companyId]);
const value = useMemo<OperationalContextValue>(() => ({
areaId,
@@ -4,7 +4,7 @@ import { useAuth } from '../../auth/AuthContext';
import { Alert, EmptyState, LoadingBlock, errorMessage } from '../../components/Feedback';
import { Icon } from '../../components/Icon';
import { getAssetLineage } from '../../lib/api';
import type { AssetLineageItem, AssetType } from '../../lib/api';
import type { AssetLineageItem } from '../../lib/api';
import {
listInventoryCompanies,
listInventoryDepartments,
@@ -18,61 +18,16 @@ import type {
} from '../../lib/inventoryBrowserApi';
import { assetOperationalStatusLabel, assetStatusClass, assetStatusLabel } from './assetPresentation';
const structuredInventorySections = [
{
code: 'empresa',
label: 'Empresas',
description: 'Maestro de empresas operadoras. Se vinculan a una o más Áreas sin formar parte del árbol territorial.',
icon: 'users' as const,
},
{
code: 'departamento',
label: 'Departamentos',
description: 'Primer nivel territorial. Desde cada Departamento se accede a sus Áreas.',
icon: 'map' as const,
},
{
code: 'area',
label: 'Áreas',
description: 'Cada Área pertenece a un Departamento y puede tener una o más Empresas operadoras vinculadas.',
icon: 'map' as const,
},
{
code: 'yacimiento',
label: 'Yacimientos',
description: 'Nivel operativo dentro de un Área. Organiza las Instalaciones que pertenecen a ese Yacimiento.',
icon: 'map' as const,
},
{
code: 'instalacion',
label: 'Instalaciones',
description: 'Elementos técnicos ubicados dentro de un Yacimiento y clasificados por su familia de Inventario.',
icon: 'layers' as const,
},
{
code: 'subinstalacion',
label: 'Subinstalaciones',
description: 'Último nivel estructural. Dependen de una Instalación y conservan su clasificación técnica.',
icon: 'layers' as const,
},
] as const;
function navigationHref(base: URLSearchParams, parentId?: string) {
const params = new URLSearchParams(base);
params.delete('view');
params.delete('page');
params.delete('section');
params.delete('companyId');
params.delete('typeId');
parentId ? params.set('parentId', parentId) : params.delete('parentId');
return `/inventarios${params.size ? `?${params}` : ''}`;
}
function inventoryTypeForSection(types: AssetType[], code: string) {
if (code === 'empresa') return types.find((type) => type.operationalRole === 'COMPANY') ?? null;
return types.find((type) => type.code.toLowerCase() === code) ?? null;
}
function DepartmentCard({ department, href }: { department: InventoryBrowserDepartment; href: string }) {
return <Link className="asset-browser-item" to={href}>
<span className="asset-browser-item-icon"><Icon name="map" size={17} /></span>
@@ -125,7 +80,7 @@ function nextLevelLabel(typeCode: string | undefined) {
}
}
export function AssetHierarchyView({ filters, types }: { filters: InventoryQuery; types: AssetType[] }) {
export function AssetHierarchyView({ filters }: { filters: InventoryQuery }) {
const { hasPermission } = useAuth();
const canCreate = hasPermission('assets.create');
const [searchParams] = useSearchParams();
@@ -168,48 +123,24 @@ export function AssetHierarchyView({ filters, types }: { filters: InventoryQuery
return <div className="asset-browser-panel">
{error && <Alert>{error}</Alert>}
<div className="asset-browser-section-heading">
<div>
<span className="eyebrow">INVENTARIOS ESTRUCTURADOS</span>
<h2>Elegí qué querés administrar</h2>
<p>La estructura queda fija y fácil de leer: Empresas como maestro de operadoras y, por separado, Departamento Área Yacimiento Instalación Subinstalación.</p>
</div>
<div><span className="eyebrow">INVENTARIO COMPLETO</span><h2>Departamentos</h2><p>Entrá por Departamento y navegá Área Yacimiento Instalación Subinstalación hasta llegar a su clasificación y Hallazgos asociados.</p></div>
<div className="asset-browser-current-actions"><span className="count-pill">{departments.length} Departamentos</span><span className="count-pill">{companies.length} Empresas</span></div>
</div>
{departments.length === 0
? <EmptyState title="Todavía no hay Departamentos" text="Creá el primer registro para comenzar a construir el Inventario manualmente." />
: <div className="asset-browser-list">{departments.map((department) => <DepartmentCard key={department.id} department={department} href={navigationHref(searchParams, department.id)} />)}</div>}
<div className="asset-browser-list" aria-label="Secciones estructuradas de Inventarios">
{structuredInventorySections.map((section, index) => {
const assetType = inventoryTypeForSection(types, section.code);
const content = <>
<span className="asset-browser-item-icon"><Icon name={section.icon} size={17} /></span>
<span className="asset-browser-item-main">
<strong>{section.label}</strong>
<small>{section.description}</small>
</span>
<span className="asset-browser-item-status"><small>{section.code === 'empresa' ? 'Maestro independiente' : `Nivel ${index}`}</small></span>
{assetType && <Icon name="chevron" size={16} />}
</>;
return assetType
? <Link key={section.code} className="asset-browser-item" to={`/inventarios?view=list&typeId=${assetType.id}`}>{content}</Link>
: <div key={section.code} className="asset-browser-item" aria-disabled="true">{content}</div>;
})}
</div>
<div className="asset-browser-levels" aria-label="Jerarquía territorial de Inventarios">
<div className="asset-browser-levels" aria-label="Estructura de Inventarios">
<div><span>1</span><strong>Departamento</strong><small>raíz territorial</small></div><i></i>
<div><span>2</span><strong>Área</strong><small>operadoras vinculadas</small></div><i></i>
<div><span>2</span><strong>Área</strong><small>dentro del Departamento</small></div><i></i>
<div><span>3</span><strong>Yacimiento</strong><small>dentro del Área</small></div><i></i>
<div><span>4</span><strong>Instalación</strong><small>clasificación técnica</small></div><i></i>
<div><span>5</span><strong>Subinstalación</strong><small>clasificación técnica</small></div>
</div>
<section className="asset-browser-group" style={{ marginTop: 20 }}>
<div className="asset-browser-group-heading">
<div><h3>Recorrido por territorio</h3><p>También podés navegar como árbol: entrá por un Departamento y descendé hasta la Instalación o Subinstalación concreta.</p></div>
<span>{departments.length}</span>
</div>
{departments.length === 0
? <EmptyState title="Todavía no hay Departamentos" text="Creá el primer registro para comenzar a construir el Inventario manualmente." />
: <div className="asset-browser-list">{departments.map((department) => <DepartmentCard key={department.id} department={department} href={navigationHref(searchParams, department.id)} />)}</div>}
<div className="asset-browser-group-heading"><div><h3>Empresas</h3><p>Maestro independiente. La Empresa se vincula al Área como operadora sin alterar la estructura física.</p></div><span>{companies.length}</span></div>
{companies.length === 0 ? <div className="inline-empty">Todavía no hay Empresas cargadas.</div> : <div className="asset-browser-list">{companies.map((company) => <CompanyCard key={company.id} company={company} />)}</div>}
</section>
</div>;
}
@@ -34,10 +34,10 @@ function supportsFunctionChange(code: string, name: string): boolean {
export function AssetHistoryPanel({
assetId,
refreshKey = 0,
refreshKey,
}: {
assetId: string;
refreshKey?: number;
refreshKey: number;
}) {
const [versions, setVersions] = useState<AssetVersionSummary[]>([]);
const [total, setTotal] = useState(0);
@@ -52,12 +52,12 @@ export function AssetMediaPanel({
assetId,
assetName,
canManage,
onChanged = () => undefined,
onChanged,
}: {
assetId: string;
assetName: string;
canManage: boolean;
onChanged?: () => void;
onChanged: () => void;
}) {
const fileRef = useRef<HTMLInputElement | null>(null);
const [items, setItems] = useState<AssetMedia[]>([]);
@@ -1,43 +0,0 @@
export interface InspectionPreventiveCandidate {
id: string;
code: string;
name: string;
typeName: string;
}
interface PreventiveCandidateResponse {
data: InspectionPreventiveCandidate[];
}
export async function listInspectionPreventiveCandidates(
visitId: string,
search = '',
): Promise<InspectionPreventiveCandidate[]> {
const query = new URLSearchParams();
const term = search.trim();
if (term) query.set('search', term);
const response = await fetch(
`/api/v3/inspection-visits/${visitId}/preventive-candidates${query.size ? `?${query}` : ''}`,
{
method: 'GET',
credentials: 'same-origin',
headers: { Accept: 'application/json' },
},
);
if (!response.ok) {
let message = 'No se pudo cargar el Inventario disponible para esta Inspección';
try {
const payload = await response.json() as { message?: string | string[] };
if (Array.isArray(payload.message)) message = payload.message.join('. ');
else if (payload.message) message = payload.message;
} catch {
// Conserva el mensaje funcional cuando la respuesta no sea JSON.
}
throw new Error(message);
}
const payload = await response.json() as PreventiveCandidateResponse;
return payload.data ?? [];
}
+2
View File
@@ -3,6 +3,7 @@ import { NavLink, Outlet, useNavigate } from 'react-router';
import { useAuth } from '../auth/AuthContext';
import { APP_PHASE, APP_VERSION } from '../config/version';
import { Icon } from '../components/Icon';
import { OperationalContextBar } from '../components/OperationalContextBar';
import type { IconName } from '../components/Icon';
interface NavItem {
@@ -117,6 +118,7 @@ export function AppLayout() {
<strong>DH Inspección</strong>
<span className="mobile-version">v{APP_VERSION}</span>
</header>
<OperationalContextBar />
<main className="content"><Outlet /></main>
<footer className="app-footer" aria-label="Versión de la aplicación">
<span>DH Inspección V2</span><span>·</span><strong>v{APP_VERSION}</strong><span>·</span><span>{APP_PHASE}</span>
+2 -12
View File
@@ -21,15 +21,7 @@ export interface InventoryTechnicalValues {
values:Record<string,unknown>;
}
export interface InventoryStructureLevel { kind:InventoryStructureKind; label:string; type:{id:string;code:string;name:string}; parentKind:InventoryStructureKind|null; requiresFamily:boolean; }
export interface InventoryStructureOption { id:string; code:string; name:string; }
export interface InventoryStructureOptions {
independentMasters:InventoryStructureLevel[];
levels:InventoryStructureLevel[];
companies:InventoryStructureOption[];
concessionTypes:InventoryStructureOption[];
installationFamilies:InventoryFamily[];
subinstallationFamilies:InventoryFamily[];
}
export interface InventoryStructureOptions { independentMasters:InventoryStructureLevel[]; levels:InventoryStructureLevel[]; installationFamilies:InventoryFamily[]; subinstallationFamilies:InventoryFamily[]; }
export interface InventoryStructureParent {
id:string; code:string; name:string; commonName:string|null; type:{id:string;code:string;name:string};
inventoryFamily:null|{id:string;code:string;name:string;level:'INSTALLATION'|'SUBINSTALLATION';informationLabels:string[]};
@@ -43,8 +35,6 @@ export interface InventoryFamilyFindings { family:InventoryFamily; items:Invento
export interface CreatedInventoryStructure {
id:string; code:string; name:string; commonName:string|null; description:string|null; informationStatus:string; operationalStatus:string;
type:{id:string;code:string;name:string}; parent:null|{id:string;code:string;name:string}; operationalArea?:null|{id:string;code:string;name:string};
operatorCompany?:null|{id:string;code:string;name:string};
concessionType?:null|{id:string;code:string;name:string};
inventoryFamily:null|{id:string;code:string;name:string;level:string;informationLabels:string[]};
}
@@ -63,4 +53,4 @@ export function updateInventoryFamily(familyId:string,input:{name?:string;parent
export function createInventoryFamilyAttribute(familyId:string,input:{code:string;name:string;dataType:InventoryFamilyAttributeDataType;isRequired?:boolean;unit?:string|null;options?:string[];sortOrder?:number}){return apiRequest<InventoryFamilyAttribute>(`/inventory-families/${familyId}/attributes`,{method:'POST',body:JSON.stringify(input)});}
export function updateInventoryFamilyAttribute(familyId:string,attributeId:string,input:{name?:string;dataType?:InventoryFamilyAttributeDataType;isRequired?:boolean;isActive?:boolean;unit?:string|null;options?:string[]|null;sortOrder?:number}){return apiRequest<InventoryFamilyAttribute>(`/inventory-families/${familyId}/attributes/${attributeId}`,{method:'PATCH',body:JSON.stringify(input)});}
export function replaceInventoryFamilyFindings(familyId:string,input:{itemIds:string[];reason:string}){return apiRequest<InventoryFamilyFindings>(`/inventory-families/${familyId}/findings`,{method:'PUT',body:JSON.stringify(input)});}
export function createInventoryStructure(input:{kind:InventoryStructureKind;code?:string|null;name:string;commonName?:string|null;parentId?:string|null;familyId?:string|null;operatorCompanyId?:string|null;concessionTypeId?:string|null;description?:string|null}){return apiRequest<CreatedInventoryStructure>('/inventory-structure',{method:'POST',body:JSON.stringify(input)});}
export function createInventoryStructure(input:{kind:InventoryStructureKind;code?:string|null;name:string;commonName?:string|null;parentId?:string|null;familyId?:string|null;description?:string|null}){return apiRequest<CreatedInventoryStructure>('/inventory-structure',{method:'POST',body:JSON.stringify(input)});}
+7 -50
View File
@@ -33,40 +33,6 @@ const quickViews = [
{ key: 'out', label: 'Fuera de servicio' },
] as const;
const structuredTypeCopy: Record<string, { title: string; description: string }> = {
departamento: {
title: 'Departamentos',
description: 'Raíz territorial del Inventario. Cada Departamento contiene sus Áreas.',
},
area: {
title: 'Áreas',
description: 'Áreas dentro de un Departamento, con sus Empresas operadoras vinculadas.',
},
yacimiento: {
title: 'Yacimientos',
description: 'Yacimientos pertenecientes a un Área y utilizados para organizar sus Instalaciones.',
},
instalacion: {
title: 'Instalaciones',
description: 'Instalaciones dentro de un Yacimiento, con clasificación técnica y contexto operativo.',
},
subinstalacion: {
title: 'Subinstalaciones',
description: 'Subinstalaciones dependientes de una Instalación, último nivel de la estructura.',
},
};
function structuredPresentation(type: AssetType | undefined) {
if (!type) return null;
if (type.operationalRole === 'COMPANY') {
return {
title: 'Empresas',
description: 'Maestro independiente de Empresas operadoras. Se vinculan a una o más Áreas.',
};
}
return structuredTypeCopy[type.code.toLowerCase()] ?? null;
}
export function AssetsPage() {
const [urlParams, setUrlParams] = useSearchParams();
const operationalContext = useOperationalContext();
@@ -92,8 +58,6 @@ export function AssetsPage() {
const needsValidation = quick === 'validation' ? true : undefined;
const hasGeometry = quick === 'location' ? false : undefined;
const effectiveOperationalStatus = quick === 'out' ? 'OUT_OF_SERVICE' as AssetOperationalStatus : operationalStatus;
const selectedType = types.find((type) => type.id === typeId);
const selectedPresentation = structuredPresentation(selectedType);
useEffect(() => {
listAssetTypes().then((data) => setTypes(data.filter((type) =>
@@ -138,23 +102,16 @@ export function AssetsPage() {
value > 1 ? next.set('page', String(value)) : next.delete('page'); setUrlParams(next);
};
const advancedActive = Boolean(typeId || status || rawOperationalStatus);
const sectionTitle = view === 'list' && selectedPresentation ? selectedPresentation.title : 'Inventarios';
const sectionDescription = view === 'list' && selectedPresentation
? selectedPresentation.description
: 'Empresas y estructura territorial: Departamento → Área → Yacimiento → Instalación → Subinstalación.';
return <section>
<div className="page-heading asset-center-heading">
<div>
<span className="eyebrow">{selectedPresentation && view === 'list' ? 'INVENTARIOS · SECCIÓN' : 'INVENTARIOS'}</span>
<h1>{sectionTitle}</h1>
<p>{sectionDescription}</p>
<span className="eyebrow">INVENTARIOS</span>
<h1>Inventarios</h1>
<p>Todos los registros: Departamento Área Yacimiento Instalación Subinstalación, más el maestro independiente de Empresas.</p>
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{selectedPresentation && view === 'list' && <Link className="button secondary" to="/inventarios"><Icon name="layers" />Ver estructura</Link>}
<PermissionGate permission="assets.create"><Link className="button primary" to="/inventarios/nuevo"><Icon name="plus" />Nuevo registro</Link></PermissionGate>
</div>
</div>
<AssetCenterTabs active={view === 'hierarchy' ? 'navigate' : 'list'} />
@@ -169,7 +126,7 @@ export function AssetsPage() {
{view === 'list' && <button type="button" className={advancedOpen || advancedActive ? 'advanced active' : 'advanced'} onClick={() => setAdvancedOpen((current) => !current)}>Más filtros</button>}
</div>
{view === 'list' && (advancedOpen || advancedActive) && <div className="advanced-filter-panel">
<label className="field compact-field"><span>Sección</span><SearchableSelect value={typeId} onChange={(event) => update({ typeId: event.target.value || null })}><option value="">Todas las secciones</option>{types.map((type) => <option key={type.id} value={type.id}>{type.operationalRole === 'COMPANY' ? 'Empresas' : structuredTypeCopy[type.code.toLowerCase()]?.title ?? type.name}</option>)}</SearchableSelect></label>
<label className="field compact-field"><span>Nivel</span><SearchableSelect value={typeId} onChange={(event) => update({ typeId: event.target.value || null })}><option value="">Todos los niveles</option>{types.map((type) => <option key={type.id} value={type.id}>{type.name}</option>)}</SearchableSelect></label>
<label className="field compact-field"><span>Estado del dato</span><SearchableSelect value={status} onChange={(event) => update({ status: event.target.value || null })}><option value="">Todos</option>{ASSET_STATUSES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</SearchableSelect></label>
<label className="field compact-field"><span>Estado operativo</span><SearchableSelect value={rawOperationalStatus} onChange={(event) => update({ operationalStatus: event.target.value || null, quick: quick === 'out' ? null : quick === 'all' ? null : quick })}><option value="">Todos</option>{ASSET_OPERATIONAL_STATUSES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</SearchableSelect></label>
<button type="button" className="button text filter-clear" onClick={clearFilters}>Limpiar filtros</button>
@@ -178,14 +135,14 @@ export function AssetsPage() {
{error && <Alert>{error}</Alert>}
{view === 'hierarchy'
? <AssetHierarchyView filters={filters} types={types} />
? <AssetHierarchyView filters={filters} />
: loading
? <LoadingBlock label="Cargando Inventarios…" />
: assets.length === 0
? <EmptyState title="Todavía no hay registros" text={selectedPresentation ? `No hay registros cargados en ${selectedPresentation.title}.` : 'La base está lista para comenzar la carga manual desde Departamento.'} />
? <EmptyState title="Todavía no hay registros" text="La base está lista para comenzar la carga manual desde Departamento." />
: <div className="table-panel compact-assets-table">
<div className="table-summary"><strong>{meta.total} registro{meta.total === 1 ? '' : 's'}</strong><span>Página {meta.page} de {Math.max(meta.totalPages, 1)}</span></div>
<div className="table-scroll"><table><thead><tr><th>Registro</th><th>Sección</th><th>Ubicación / Operadora</th><th>Estado</th><th>Actualizado</th><th aria-label="Acciones" /></tr></thead><tbody>
<div className="table-scroll"><table><thead><tr><th>Registro</th><th>Nivel</th><th>Ubicación / Operadora</th><th>Estado</th><th>Actualizado</th><th aria-label="Acciones" /></tr></thead><tbody>
{assets.map((asset) => <tr key={asset.id}>
<td><div className="asset-cell"><span className="asset-symbol"><Icon name="layers" size={16} /></span><div><Link to={`/inventarios/${asset.id}`} className="table-primary">{asset.name}</Link><small>{asset.code}{asset.parent ? ` · en ${asset.parent.name}` : ''}</small></div></div></td>
<td><span className="tag">{asset.type.name}</span></td>
@@ -1,203 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router';
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
import { Icon } from '../components/Icon';
import { getFindingCatalogAdmin, listAssetTypes } from '../lib/api';
import type { AssetType, FindingAdminCatalog } from '../lib/api';
import { listInventoryFamiliesAdmin } from '../lib/inventoryStructureApi';
import type { InventoryFamily } from '../lib/inventoryStructureApi';
type CanonicalKind = 'EMPRESA' | 'DEPARTAMENTO' | 'AREA' | 'YACIMIENTO' | 'INSTALACION' | 'SUBINSTALACION';
type StructuralField = {
label: string;
detail: string;
required?: boolean;
relation?: boolean;
};
const LEVELS: Array<{ kind: CanonicalKind; label: string; description: string; fields: StructuralField[] }> = [
{
kind: 'EMPRESA',
label: 'Empresa',
description: 'Maestro independiente. La Empresa se relaciona directamente con uno o más Yacimientos.',
fields: [{ label: 'Nombre', detail: 'Nombre de la Empresa / Operadora.', required: true }],
},
{
kind: 'DEPARTAMENTO',
label: 'Departamento',
description: 'Raíz territorial. No depende de ningún otro nivel.',
fields: [{ label: 'Nombre', detail: 'Nombre del Departamento.', required: true }],
},
{
kind: 'AREA',
label: 'Área',
description: 'El Área sólo tiene Nombre y pertenece obligatoriamente a un Departamento.',
fields: [
{ label: 'Departamento', detail: 'Relación obligatoria Departamento → Área.', required: true, relation: true },
{ label: 'Nombre', detail: 'Nombre del Área.', required: true },
],
},
{
kind: 'YACIMIENTO',
label: 'Yacimiento',
description: 'El Yacimiento concentra el contexto operativo: Área, Tipo de concesión y Empresa relacionada.',
fields: [
{ label: 'Área', detail: 'Relación obligatoria Área → Yacimiento.', required: true, relation: true },
{ label: 'Tipo de concesión', detail: 'Explotación o Exploración, según la fuente.', required: true, relation: true },
{ label: 'Empresa relacionada', detail: 'Empresa / Operadora asociada directamente al Yacimiento.', required: true, relation: true },
{ label: 'Nombre', detail: 'Nombre del Yacimiento. Puede repetirse en otra Área.', required: true },
],
},
{
kind: 'INSTALACION',
label: 'Instalación',
description: 'Cada Instalación pertenece a un Yacimiento y posee una clasificación técnica.',
fields: [
{ label: 'Yacimiento', detail: 'Relación obligatoria Yacimiento → Instalación.', required: true, relation: true },
{ label: 'Tipo de instalación', detail: 'Clasificación técnica tomada del modelo de Instalaciones.', required: true, relation: true },
],
},
{
kind: 'SUBINSTALACION',
label: 'Subinstalación',
description: 'Cada Subinstalación pertenece a una Instalación y usa una clasificación compatible con ella.',
fields: [
{ label: 'Instalación', detail: 'Relación obligatoria Instalación → Subinstalación.', required: true, relation: true },
{ label: 'Tipo de subinstalación', detail: 'Clasificación técnica compatible con el Tipo de instalación padre.', required: true, relation: true },
],
},
];
const EMPTY_CATALOG: FindingAdminCatalog = { categories: [], items: [] };
function canonicalType(types: AssetType[], kind: CanonicalKind): AssetType | null {
if (kind === 'EMPRESA') return types.find((type) => type.operationalRole === 'COMPANY' && type.isActive) ?? null;
if (kind === 'AREA') return types.find((type) => type.operationalRole === 'AREA' && type.isActive) ?? null;
return types.find((type) => type.code.toLowerCase() === kind.toLowerCase() && type.isActive) ?? null;
}
export function AuthoritativeInventoryConfigPage() {
const [types, setTypes] = useState<AssetType[]>([]);
const [families, setFamilies] = useState<InventoryFamily[]>([]);
const [catalog, setCatalog] = useState<FindingAdminCatalog>(EMPTY_CATALOG);
const [selectedKind, setSelectedKind] = useState<CanonicalKind>('AREA');
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
Promise.all([listAssetTypes(), listInventoryFamiliesAdmin(), getFindingCatalogAdmin()])
.then(([loadedTypes, loadedFamilies, loadedCatalog]) => {
setTypes(loadedTypes);
setFamilies(loadedFamilies);
setCatalog(loadedCatalog);
})
.catch((requestError) => setError(errorMessage(requestError)))
.finally(() => setLoading(false));
}, []);
const level = LEVELS.find((item) => item.kind === selectedKind) ?? LEVELS[0]!;
const selectedType = canonicalType(types, selectedKind);
const commonAttributes = useMemo(() => {
const attributes = selectedType?.attributes.filter((attribute) => attribute.isActive) ?? [];
return selectedKind === 'INSTALACION'
? attributes.filter((attribute) => attribute.code !== 'tipo_instalacion')
: attributes;
}, [selectedKind, selectedType]);
const installationFamilies = families.filter((family) => family.level === 'INSTALLATION' && family.isActive !== false);
const subinstallationFamilies = families.filter((family) => family.level === 'SUBINSTALLATION' && family.isActive !== false);
const activeFindings = catalog.items.filter((item) => item.isActive).length;
if (loading) return <LoadingBlock label="Cargando configuración de Inventarios…" />;
return <section className="inventory-config-page">
<div className="page-heading">
<div>
<span className="eyebrow">ADMINISTRACIÓN · MODELO AUTORITATIVO</span>
<h1>Configuración de Inventarios</h1>
<p>Relaciones fijas cargadas desde los SQL definitivos. Los vínculos estructurales no son campos de texto editables.</p>
</div>
<Link className="button primary" to="/inventarios/nuevo"><Icon name="plus" />Agregar registro</Link>
</div>
{error && <Alert>{error}</Alert>}
<article className="panel" style={{ marginBottom: 18 }}>
<div className="panel-heading">
<div>
<span className="eyebrow">ESTRUCTURA FÍSICA</span>
<h2>Jerarquía obligatoria</h2>
<p className="section-copy">Empresa es un maestro independiente y se asocia al Yacimiento. El árbol físico queda separado y sin ambigüedades.</p>
</div>
</div>
<div className="asset-browser-levels" aria-label="Jerarquía de Inventarios">
<div><span>1</span><strong>Departamento</strong><small>raíz</small></div><i></i>
<div><span>2</span><strong>Área</strong><small>Departamento</small></div><i></i>
<div><span>3</span><strong>Yacimiento</strong><small>Área + Empresa + concesión</small></div><i></i>
<div><span>4</span><strong>Instalación</strong><small>Yacimiento</small></div><i></i>
<div><span>5</span><strong>Subinstalación</strong><small>Instalación</small></div>
</div>
<div className="temporal-notice" style={{ marginTop: 14 }}>
<Icon name="users" />
<p><strong>Empresa:</strong> ya no pertenece al Área. La relación canónica es <strong>Yacimiento Empresa relacionada</strong>.</p>
</div>
</article>
<article className="panel" style={{ marginBottom: 18 }}>
<div className="panel-heading">
<div>
<span className="eyebrow">INFORMACIÓN ESTRUCTURAL</span>
<h2>{level.label}</h2>
<p className="section-copy">{level.description}</p>
</div>
<span className="tag">Modelo SQL</span>
</div>
<div className="quick-view-row" style={{ marginBottom: 18 }}>
{LEVELS.map((item) => <button type="button" key={item.kind} className={selectedKind === item.kind ? 'active' : ''} onClick={() => setSelectedKind(item.kind)}>{item.label}</button>)}
</div>
<div className="attribute-list">
{level.fields.map((field, index) => <div className="attribute-card" key={field.label}>
<span className="attribute-order">{index + 1}</span>
<span>
<strong>{field.label}</strong>
<small>{field.detail}</small>
</span>
<span className="attribute-flags">
{field.relation && <span className="tag">Relación</span>}
{field.required && <span className="tag">Obligatorio</span>}
</span>
</div>)}
{commonAttributes.map((attribute, index) => <div className="attribute-card" key={attribute.id}>
<span className="attribute-order">{level.fields.length + index + 1}</span>
<span>
<strong>{attribute.name}</strong>
<small>{attribute.code} · Campo común del nivel</small>
</span>
<span className="attribute-flags">{attribute.isRequired && <span className="tag">Obligatorio</span>}</span>
</div>)}
</div>
</article>
<div className="dashboard-grid" style={{ alignItems: 'start' }}>
<article className="panel">
<div className="panel-heading"><div><span className="eyebrow">MODELO TÉCNICO</span><h2>Tipos de Instalación</h2><p className="section-copy">{installationFamilies.length} clasificaciones exactas del SQL.</p></div></div>
<div className="attribute-list" style={{ maxHeight: 520, overflow: 'auto' }}>
{installationFamilies.map((family) => <div className="attribute-card" key={family.id}><span className="asset-symbol"><Icon name="layers" size={16} /></span><span><strong>{family.name}</strong><small>{family.findingCount ?? 0} Hallazgos asociados</small></span><span className="tag">{family.code}</span></div>)}
</div>
</article>
<article className="panel">
<div className="panel-heading"><div><span className="eyebrow">MODELO TÉCNICO</span><h2>Tipos de Subinstalación</h2><p className="section-copy">{subinstallationFamilies.length} clasificaciones, cada una vinculada a su Tipo de instalación.</p></div></div>
<div className="attribute-list" style={{ maxHeight: 520, overflow: 'auto' }}>
{subinstallationFamilies.map((family) => <div className="attribute-card" key={family.id}><span className="asset-symbol"><Icon name="layers" size={16} /></span><span><strong>{family.name}</strong><small>{family.parentFamilies.map((parent) => parent.name).join(' · ') || 'Sin padre'} · {family.findingCount ?? 0} Hallazgos</small></span></div>)}
</div>
</article>
</div>
<article className="panel" style={{ marginTop: 18 }}>
<div className="panel-heading">
<div><span className="eyebrow">HALLAZGOS</span><h2>Catálogo contextual</h2><p className="section-copy">{activeFindings} Hallazgos cargados con sus relaciones exactas a Instalaciones y Subinstalaciones.</p></div>
<Link className="button secondary" to="/admin/finding-catalog">Abrir catálogo <Icon name="chevron" /></Link>
</div>
</article>
</section>;
}
-437
View File
@@ -1,437 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import type { FormEvent } from 'react';
import { Link, useSearchParams } from 'react-router';
import { useAuth } from '../auth/AuthContext';
import { Alert, EmptyState, LoadingBlock, errorMessage } from '../components/Feedback';
import { Icon } from '../components/Icon';
import { SearchableSelect } from '../components/SearchableSelect';
import { AssetHistoryPanel } from '../features/assets/AssetHistoryPanel';
import { AssetMediaPanel } from '../features/assets/AssetMediaPanel';
import { assetStatusClass, assetStatusLabel } from '../features/assets/assetPresentation';
import {
createAreaCompanyRelation,
endAreaCompanyRelation,
getAssetRegistry,
listAreaCompanyRelations,
listInspectionVisits,
listOperationalAreas,
updateAsset,
upsertOrganizationProfile,
} from '../lib/api';
import type {
AreaCompanyRelation,
AssetDetail,
AssetRegistry,
OperationalAssetSummary,
OrganizationKind,
} from '../lib/api';
import {
listInspectionActsGlobalF4,
type InspectionActListItemF4,
type InspectionActStatusF4,
} from '../lib/inspectionActF4Api';
import {
listInspectionReportsF4,
type InspectionReportDetailF4,
} from '../lib/reportWorkflowApi';
import { formatDate } from '../lib/format';
import './companyInventory.css';
type CompanyTab = 'summary' | 'issued' | 'documents' | 'history';
const organizationLabels: Record<OrganizationKind, string> = {
COMPANY: 'Empresa',
UTE: 'UTE',
PUBLIC_ENTITY: 'Entidad pública',
OTHER: 'Otra organización',
};
function relationRoleLabel(role: AreaCompanyRelation['relationRole']) {
if (role === 'OPERATOR') return 'Operadora';
if (role === 'TECHNICAL_OPERATOR') return 'Operadora técnica';
if (role === 'CONCESSIONAIRE') return 'Concesionaria / titular';
if (role === 'PERMIT_HOLDER') return 'Permisionaria';
if (role === 'PARTICIPANT') return 'Participante';
return 'Otro vínculo';
}
function reportStatusLabel(report: InspectionReportDetailF4) {
if (report.status === 'OFFICIALIZED') return report.gedoIfIdentifier ? `GEDO · ${report.gedoIfIdentifier}` : 'Oficializado en GEDO';
if (report.status === 'WORKING') return 'INF en preparación';
if (report.status === 'CANCELLED') return 'Cancelado';
return 'Informe histórico';
}
function reportStatusClass(report: InspectionReportDetailF4) {
if (report.status === 'OFFICIALIZED') return 'active';
if (report.status === 'CANCELLED') return 'danger';
return 'pending';
}
function areaLabel(items: Array<{ name: string }>) {
if (!items.length) return 'Área sin informar';
const first = items[0];
if (!first) return 'Área sin informar';
return items.length === 1 ? first.name : `${first.name} +${items.length - 1}`;
}
async function loadAllActs(companyId: string): Promise<InspectionActListItemF4[]> {
const statuses: InspectionActStatusF4[] = ['SEALED', 'CLOSED', 'RECTIFIED'];
const groups = await Promise.all(statuses.map(async (status) => {
const collected: InspectionActListItemF4[] = [];
let page = 1;
let totalPages = 1;
do {
const response = await listInspectionActsGlobalF4({ companyId, status, page, pageSize: 100 });
collected.push(...response.data);
totalPages = response.meta.totalPages;
page += 1;
} while (page <= totalPages);
return collected;
}));
return groups.flat().sort((a, b) => new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime());
}
async function loadAllReports(companyId: string): Promise<InspectionReportDetailF4[]> {
const collected: InspectionReportDetailF4[] = [];
let page = 1;
let totalPages = 1;
do {
const response = await listInspectionReportsF4({ companyId, page, pageSize: 100 });
collected.push(...response.data);
totalPages = response.meta.totalPages;
page += 1;
} while (page <= totalPages);
return collected
.filter((item) => item.status !== 'CANCELLED')
.sort((a, b) => new Date(b.generatedAt).getTime() - new Date(a.generatedAt).getTime());
}
export function CompanyInventoryPage({ initialAsset }: { initialAsset: AssetDetail }) {
const [params, setParams] = useSearchParams();
const requestedTab = params.get('tab') as CompanyTab | null;
const tab: CompanyTab = requestedTab && ['summary', 'issued', 'documents', 'history'].includes(requestedTab)
? requestedTab
: 'summary';
const { hasPermission } = useAuth();
const canEditAsset = hasPermission('assets.update');
const canReadRegistry = hasPermission('asset_registry.read');
const canManageRegistry = hasPermission('asset_registry.manage');
const canReadRelations = hasPermission('asset_relations.read');
const canManageRelations = hasPermission('asset_relations.manage');
const canReadActs = hasPermission('inspection_acts.read');
const canReadReports = hasPermission('inspection_reports.read');
const canReadInspections = hasPermission('inspections.read');
const canReadMedia = hasPermission('assets.read_media');
const canManageMedia = hasPermission('assets.manage_media');
const canReadHistory = hasPermission('assets.read_history');
const [asset, setAsset] = useState(initialAsset);
const [registry, setRegistry] = useState<AssetRegistry | null>(null);
const [relations, setRelations] = useState<AreaCompanyRelation[]>([]);
const [areas, setAreas] = useState<OperationalAssetSummary[]>([]);
const [loadingContext, setLoadingContext] = useState(true);
const [contextError, setContextError] = useState('');
const [success, setSuccess] = useState('');
const [editing, setEditing] = useState(false);
const [saving, setSaving] = useState(false);
const [companyForm, setCompanyForm] = useState({
name: initialAsset.name,
commonName: initialAsset.commonName ?? '',
organizationKind: 'COMPANY' as OrganizationKind,
legalName: initialAsset.name,
taxId: '',
notificationEmail: '',
notes: '',
});
const [relationForm, setRelationForm] = useState({ areaId: '', reason: '' });
const [metrics, setMetrics] = useState({ acts: 0, reports: 0, activeInspections: 0 });
const [acts, setActs] = useState<InspectionActListItemF4[]>([]);
const [reports, setReports] = useState<InspectionReportDetailF4[]>([]);
const [documentsLoading, setDocumentsLoading] = useState(false);
const [documentsError, setDocumentsError] = useState('');
const [documentSearch, setDocumentSearch] = useState('');
const activeRelations = useMemo(() => relations.filter((item) => item.active), [relations]);
const loadContext = async () => {
setLoadingContext(true);
setContextError('');
try {
const [loadedRegistry, loadedRelations, loadedAreas] = await Promise.all([
canReadRegistry ? getAssetRegistry(asset.id) : Promise.resolve(null),
canReadRelations ? listAreaCompanyRelations({ companyId: asset.id, includeHistory: true }) : Promise.resolve([]),
canManageRelations ? listOperationalAreas() : Promise.resolve([]),
]);
setRegistry(loadedRegistry);
setRelations(loadedRelations);
setAreas(loadedAreas);
if (loadedRegistry?.organizationProfile) {
const profile = loadedRegistry.organizationProfile;
setCompanyForm({
name: asset.name,
commonName: asset.commonName ?? '',
organizationKind: profile.organizationKind,
legalName: profile.legalName ?? asset.name,
taxId: profile.taxId ?? '',
notificationEmail: profile.notificationEmail ?? '',
notes: profile.notes ?? '',
});
}
} catch (requestError) {
setContextError(errorMessage(requestError));
} finally {
setLoadingContext(false);
}
};
useEffect(() => { void loadContext(); }, [asset.id]);
useEffect(() => {
let cancelled = false;
const loadMetrics = async () => {
try {
const [sealed, closed, rectified, reportPage, planned, inProgress] = await Promise.all([
canReadActs ? listInspectionActsGlobalF4({ companyId: asset.id, status: 'SEALED', pageSize: 1 }) : Promise.resolve(null),
canReadActs ? listInspectionActsGlobalF4({ companyId: asset.id, status: 'CLOSED', pageSize: 1 }) : Promise.resolve(null),
canReadActs ? listInspectionActsGlobalF4({ companyId: asset.id, status: 'RECTIFIED', pageSize: 1 }) : Promise.resolve(null),
canReadReports ? listInspectionReportsF4({ companyId: asset.id, pageSize: 1 }) : Promise.resolve(null),
canReadInspections ? listInspectionVisits({ companyId: asset.id, status: 'PLANNED', pageSize: 1 }) : Promise.resolve(null),
canReadInspections ? listInspectionVisits({ companyId: asset.id, status: 'IN_PROGRESS', pageSize: 1 }) : Promise.resolve(null),
]);
if (cancelled) return;
setMetrics({
acts: (sealed?.meta.total ?? 0) + (closed?.meta.total ?? 0) + (rectified?.meta.total ?? 0),
reports: reportPage?.meta.total ?? 0,
activeInspections: (planned?.meta.total ?? 0) + (inProgress?.meta.total ?? 0),
});
} catch {
if (!cancelled) setMetrics({ acts: 0, reports: 0, activeInspections: 0 });
}
};
void loadMetrics();
return () => { cancelled = true; };
}, [asset.id, canReadActs, canReadReports, canReadInspections]);
useEffect(() => {
if (tab !== 'issued' || (!canReadActs && !canReadReports)) return;
let cancelled = false;
setDocumentsLoading(true);
setDocumentsError('');
Promise.all([
canReadActs ? loadAllActs(asset.id) : Promise.resolve([]),
canReadReports ? loadAllReports(asset.id) : Promise.resolve([]),
])
.then(([loadedActs, loadedReports]) => {
if (cancelled) return;
setActs(loadedActs);
setReports(loadedReports);
})
.catch((requestError) => { if (!cancelled) setDocumentsError(errorMessage(requestError)); })
.finally(() => { if (!cancelled) setDocumentsLoading(false); });
return () => { cancelled = true; };
}, [tab, asset.id, canReadActs, canReadReports]);
const saveCompany = async (event: FormEvent) => {
event.preventDefault();
setSaving(true); setContextError(''); setSuccess('');
try {
let savedAsset = asset;
if (canEditAsset) {
savedAsset = await updateAsset(asset.id, {
name: companyForm.name.trim(),
commonName: companyForm.commonName.trim() || null,
});
setAsset(savedAsset);
}
if (canManageRegistry) {
await upsertOrganizationProfile(asset.id, {
organizationKind: companyForm.organizationKind,
legalName: companyForm.legalName.trim() || null,
taxId: companyForm.taxId.trim() || null,
notificationEmail: companyForm.notificationEmail.trim() || null,
notes: companyForm.notes.trim() || null,
});
}
setEditing(false);
setSuccess('Datos de la empresa actualizados.');
await loadContext();
} catch (requestError) {
setContextError(errorMessage(requestError));
} finally {
setSaving(false);
}
};
const addRelation = async (event: FormEvent) => {
event.preventDefault();
if (!relationForm.areaId || relationForm.reason.trim().length < 3) return;
setSaving(true); setContextError(''); setSuccess('');
try {
await createAreaCompanyRelation({
areaId: relationForm.areaId,
companyId: asset.id,
reason: relationForm.reason.trim(),
});
setRelationForm({ areaId: '', reason: '' });
setSuccess('Área vinculada a la empresa.');
await loadContext();
} catch (requestError) {
setContextError(errorMessage(requestError));
} finally {
setSaving(false);
}
};
const endRelation = async (relation: AreaCompanyRelation) => {
const reason = window.prompt(`Motivo para finalizar el vínculo con ${relation.area.name}`);
if (!reason?.trim() || reason.trim().length < 3) return;
setSaving(true); setContextError(''); setSuccess('');
try {
await endAreaCompanyRelation(relation.id, reason.trim());
setSuccess('Vínculo finalizado. El historial quedó conservado.');
await loadContext();
} catch (requestError) {
setContextError(errorMessage(requestError));
} finally {
setSaving(false);
}
};
const setTab = (next: CompanyTab) => {
const nextParams = new URLSearchParams(params);
next === 'summary' ? nextParams.delete('tab') : nextParams.set('tab', next);
nextParams.delete('advanced');
setParams(nextParams);
};
const reportByAct = useMemo(() => new Map(reports.map((report) => [report.actId, report])), [reports]);
const filteredActs = useMemo(() => {
const needle = documentSearch.trim().toLocaleLowerCase('es');
if (!needle) return acts;
return acts.filter((act) => [
act.code,
act.title,
act.visit.code,
...act.areas.map((item) => item.name),
reportByAct.get(act.id)?.code ?? '',
reportByAct.get(act.id)?.gedoIfIdentifier ?? '',
].join(' ').toLocaleLowerCase('es').includes(needle));
}, [acts, documentSearch, reportByAct]);
const usedAreaIds = new Set(activeRelations.map((relation) => relation.area.id));
const availableAreas = areas.filter((item) => !usedAreaIds.has(item.id));
const profile = registry?.organizationProfile ?? null;
return <section className="narrow-section company-page">
<nav className="breadcrumb company-breadcrumb" aria-label="Ruta de empresa">
<Link to="/inventarios">Inventarios</Link><span></span>
<Link to="/inventarios?section=companies">Empresas</Link><span></span><strong>{asset.name}</strong>
</nav>
<header className="company-hero">
<div>
<span className="eyebrow">EMPRESA</span>
<h1>{asset.name}</h1>
<p><strong>{asset.code}</strong>{profile?.taxId ? ` · CUIT ${profile.taxId}` : ''}</p>
</div>
<div className="company-hero-actions">
<span className={`status-badge ${assetStatusClass(asset.informationStatus)}`}>{assetStatusLabel(asset.informationStatus)}</span>
{(canEditAsset || canManageRegistry) && tab === 'summary' && <button type="button" className="button primary" onClick={() => setEditing((current) => !current)}><Icon name="edit" />{editing ? 'Cerrar edición' : 'Editar empresa'}</button>}
</div>
</header>
<nav className="company-tabs" aria-label="Secciones de la empresa">
<button type="button" className={tab === 'summary' ? 'active' : ''} onClick={() => setTab('summary')}>Resumen</button>
{(canReadActs || canReadReports) && <button type="button" className={tab === 'issued' ? 'active' : ''} onClick={() => setTab('issued')}>Actas e informes</button>}
{(canReadRegistry || canReadMedia) && <button type="button" className={tab === 'documents' ? 'active' : ''} onClick={() => setTab('documents')}>Documentación</button>}
{canReadHistory && <button type="button" className={tab === 'history' ? 'active' : ''} onClick={() => setTab('history')}>Historial</button>}
</nav>
{contextError && <Alert>{contextError}</Alert>}
{success && <Alert type="success">{success}</Alert>}
{tab === 'summary' && <div className="company-stack">
{loadingContext ? <LoadingBlock label="Cargando empresa…" /> : <>
<article className="panel company-card">
<div className="company-card-heading">
<div><span className="eyebrow">IDENTIDAD</span><h2>Datos de la empresa</h2><p>La información que se usa para identificarla y enviar documentación oficial.</p></div>
</div>
{editing ? <form className="company-edit-form" onSubmit={saveCompany}>
<div className="form-grid">
<label className="field"><span>Nombre</span><input value={companyForm.name} disabled={!canEditAsset} onChange={(event) => setCompanyForm({ ...companyForm, name: event.target.value })} required /></label>
<label className="field"><span>Nombre corto / sigla <em>opcional</em></span><input value={companyForm.commonName} disabled={!canEditAsset} onChange={(event) => setCompanyForm({ ...companyForm, commonName: event.target.value })} placeholder="Ej.: EMESA" /></label>
<label className="field"><span>Tipo</span><SearchableSelect value={companyForm.organizationKind} disabled={!canManageRegistry} onChange={(event) => setCompanyForm({ ...companyForm, organizationKind: event.target.value as OrganizationKind })}>{Object.entries(organizationLabels).map(([value, label]) => <option key={value} value={value}>{label}</option>)}</SearchableSelect></label>
<label className="field"><span>Razón social <em>opcional</em></span><input value={companyForm.legalName} disabled={!canManageRegistry} onChange={(event) => setCompanyForm({ ...companyForm, legalName: event.target.value })} /></label>
<label className="field"><span>CUIT <em>opcional</em></span><input value={companyForm.taxId} disabled={!canManageRegistry} onChange={(event) => setCompanyForm({ ...companyForm, taxId: event.target.value })} /></label>
<label className="field"><span>Email oficial para actas <em>opcional</em></span><input type="email" value={companyForm.notificationEmail} disabled={!canManageRegistry} onChange={(event) => setCompanyForm({ ...companyForm, notificationEmail: event.target.value })} /></label>
</div>
<label className="field"><span>Notas <em>opcional</em></span><textarea rows={2} value={companyForm.notes} disabled={!canManageRegistry} onChange={(event) => setCompanyForm({ ...companyForm, notes: event.target.value })} /></label>
<div className="form-actions"><button type="button" className="button secondary" onClick={() => setEditing(false)}>Cancelar</button><button className="button primary" disabled={saving}>{saving ? 'Guardando…' : 'Guardar cambios'}</button></div>
</form> : <div className="company-data-grid">
<div><small>Razón social</small><strong>{profile?.legalName || asset.name}</strong></div>
<div><small>Tipo</small><strong>{organizationLabels[profile?.organizationKind ?? 'COMPANY']}</strong></div>
<div><small>CUIT</small><strong>{profile?.taxId || 'Sin informar'}</strong></div>
<div><small>Email oficial para actas</small><strong>{profile?.notificationEmail || 'Sin informar'}</strong></div>
</div>}
</article>
<article className="panel company-card">
<div className="company-card-heading">
<div><span className="eyebrow">OPERACIÓN</span><h2>Áreas donde opera</h2><p>Estas relaciones indican dónde participa la empresa. No cambian la jerarquía física del Inventario.</p></div>
<span className="count-pill">{activeRelations.length}</span>
</div>
{activeRelations.length === 0 ? <EmptyState title="Sin áreas vinculadas" text="Todavía no hay áreas operativas asociadas a esta empresa." /> : <div className="company-area-list">{activeRelations.map((relation) => <div className="company-area-row" key={relation.id}>
<Link to={`/inventarios/${relation.area.id}`}><strong>{relation.area.name}</strong><small>{relation.area.code} · {relationRoleLabel(relation.relationRole)}</small></Link>
<div><span className="status-badge active">Activa</span>{canManageRelations && <button type="button" className="button text compact" disabled={saving} onClick={() => void endRelation(relation)}>Finalizar</button>}</div>
</div>)}</div>}
{canManageRelations && <details className="company-inline-action"><summary><Icon name="plus" size={15} />Vincular otra área</summary><form onSubmit={addRelation}><div className="form-grid"><label className="field"><span>Área</span><SearchableSelect value={relationForm.areaId} onChange={(event) => setRelationForm({ ...relationForm, areaId: event.target.value })} required><option value="">Seleccionar área</option>{availableAreas.map((item) => <option key={item.id} value={item.id}>{item.name} · {item.code}</option>)}</SearchableSelect></label><label className="field"><span>Motivo</span><input value={relationForm.reason} onChange={(event) => setRelationForm({ ...relationForm, reason: event.target.value })} minLength={3} required placeholder="Ej.: operación vigente" /></label></div><button className="button primary" disabled={saving || !relationForm.areaId || relationForm.reason.trim().length < 3}>Vincular área</button></form></details>}
</article>
<article className="panel company-card company-activity-card">
<div className="company-card-heading"><div><span className="eyebrow">INSPECCIONES</span><h2>Actividad de la empresa</h2><p>Acceso rápido a la documentación que ya fue emitida.</p></div></div>
<div className="company-metrics">
<div><small>Actas emitidas</small><strong>{metrics.acts}</strong></div>
<div><small>Informes</small><strong>{metrics.reports}</strong></div>
<div><small>Inspecciones activas</small><strong>{metrics.activeInspections}</strong></div>
</div>
{(canReadActs || canReadReports) && <div className="company-card-actions"><button type="button" className="button primary" onClick={() => setTab('issued')}>Ver actas e informes <Icon name="chevron" /></button></div>}
</article>
</>}
</div>}
{tab === 'issued' && <div className="company-stack">
<article className="panel company-card">
<div className="company-card-heading company-doc-heading"><div><span className="eyebrow">DOCUMENTACIÓN EMITIDA</span><h2>Actas e informes</h2><p>Historial documental de esta empresa. Sólo se muestran Actas ya emitidas y sus Informes asociados.</p></div><div className="company-doc-counts"><span>{acts.length} actas</span><span>{reports.length} informes</span></div></div>
<label className="company-document-search"><Icon name="search" /><input value={documentSearch} onChange={(event) => setDocumentSearch(event.target.value)} placeholder="Buscar por acta, informe, inspección, área o GEDO" /></label>
{documentsError && <Alert>{documentsError}</Alert>}
{documentsLoading ? <LoadingBlock label="Cargando documentación emitida…" /> : filteredActs.length === 0 ? <EmptyState title="Sin documentación emitida" text="Todavía no hay Actas selladas para esta empresa." /> : <div className="table-scroll company-doc-table"><table><thead><tr><th>Fecha</th><th>Área</th><th>Acta</th><th>Inspección</th><th>Informe</th><th>Oficialización</th><th /></tr></thead><tbody>{filteredActs.map((act) => {
const report = reportByAct.get(act.id);
return <tr key={act.id}>
<td>{formatDate(act.occurredAt)}</td>
<td><strong className="table-primary">{areaLabel(act.areas)}</strong></td>
<td><Link className="text-link" to={`/inspecciones/actas/${act.id}`}>{act.code}</Link></td>
<td><Link className="text-link" to={`/inspecciones/${act.visitId}`}>{act.visit.code}</Link></td>
<td>{report ? <Link className="text-link" to={`/informes/${report.id}`}>{report.code}</Link> : <span className="status-badge pending">Pendiente de INF</span>}</td>
<td>{report ? <span className={`status-badge ${reportStatusClass(report)}`}>{reportStatusLabel(report)}</span> : <span className="muted"></span>}</td>
<td className="action-cell"><Link className="icon-button" to={`/inspecciones/actas/${act.id}`} aria-label={`Abrir ${act.code}`}><Icon name="chevron" /></Link></td>
</tr>;
})}</tbody></table></div>}
</article>
</div>}
{tab === 'documents' && <div className="company-stack">
{loadingContext ? <LoadingBlock label="Cargando documentación…" /> : <>
{canReadRegistry && <article className="panel company-card">
<div className="company-card-heading"><div><span className="eyebrow">RESPALDO</span><h2>Documentos de la empresa</h2><p>Documentos e identificadores que respaldan los datos institucionales.</p></div><Link className="button secondary" to={`/inventarios/${asset.id}?advanced=1&tab=registry`}>Administrar</Link></div>
{registry?.sourceDocuments.length ? <div className="company-document-list">{registry.sourceDocuments.map((document) => <div key={document.linkId}><div><strong>{document.title}</strong><small>{document.documentNumber || document.documentType}{document.issuer ? ` · ${document.issuer}` : ''}</small></div><span className="tag">{document.relationType}</span></div>)}</div> : <div className="inline-empty">Todavía no hay documentos respaldatorios vinculados.</div>}
{registry?.externalIdentifiers.length ? <details className="company-more"><summary>Ver identificadores externos ({registry.externalIdentifiers.length})</summary><div className="company-identifier-list">{registry.externalIdentifiers.map((item) => <div key={item.id}><strong>{item.namespace}</strong><span>{item.value}</span><small>{item.validUntil ? 'Histórico' : 'Vigente'}</small></div>)}</div></details> : null}
</article>}
{canReadMedia && <AssetMediaPanel assetId={asset.id} assetName={asset.name} canManage={canManageMedia} />}
<div className="company-advanced-link"><span>¿Necesitás procedencia, composición UTE u otros datos técnicos?</span><Link to={`/inventarios/${asset.id}?advanced=1&tab=registry`}>Abrir administración avanzada</Link></div>
</>}
</div>}
{tab === 'history' && canReadHistory && <AssetHistoryPanel assetId={asset.id} />}
</section>;
}
@@ -1,6 +1,5 @@
import { useEffect, useState } from 'react';
import { Link, useParams } from 'react-router';
import { DocumentPdfProjection } from '../components/DocumentPdfProjection';
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
import { Icon } from '../components/Icon';
import {
@@ -71,7 +70,6 @@ export function InspectionActEditorPage() {
<div className="inspection-act-asset-grid">{act.assets.map((asset) => <div className="inspection-member selected" key={asset.id}><span><strong>{asset.name}</strong><small>{asset.code} · {asset.typeName}</small></span></div>)}</div>
</section>}
{act && <DocumentPdfProjection kind="act" />}
{act && <InspectionFindingsPanel act={act} />}
{act && <InspectionClosurePanel act={act} />}
@@ -4,7 +4,6 @@ import { Link, useNavigate } from 'react-router';
import { Alert, LoadingBlock } from '../components/Feedback';
import { Icon } from '../components/Icon';
import { SearchableSelect } from '../components/SearchableSelect';
import { apiRequest } from '../lib/api';
interface Option {
id: string;
@@ -24,14 +23,30 @@ interface CreatedInspection {
code: string;
}
function inspectorName(person: Inspector): string {
return `${person.firstName} ${person.lastName}`.trim() || person.username;
async function requestJson<T>(url: string, init?: RequestInit): Promise<T> {
const response = await fetch(`/api/v3${url}`, {
credentials: 'same-origin',
...init,
headers: {
Accept: 'application/json',
...(init?.body ? { 'Content-Type': 'application/json' } : {}),
...(init?.headers ?? {}),
},
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
const message = typeof payload?.message === 'string'
? payload.message
: typeof payload?.error?.message === 'string'
? payload.error.message
: `No se pudo completar la operación (${response.status}).`;
throw new Error(message);
}
return payload as T;
}
function currentLocalDateTime(): string {
const now = new Date();
const local = new Date(now.getTime() - now.getTimezoneOffset() * 60_000);
return local.toISOString().slice(0, 16);
function inspectorName(person: Inspector): string {
return `${person.firstName} ${person.lastName}`.trim() || person.username;
}
export function InspectionVisitCreateF61Page() {
@@ -46,17 +61,16 @@ export function InspectionVisitCreateF61Page() {
const [yacimientoId, setYacimientoId] = useState('');
const [operatorId, setOperatorId] = useState('');
const [inspectorId, setInspectorId] = useState('');
const [plannedStartAt, setPlannedStartAt] = useState(currentLocalDateTime);
const [plannedStartAt, setPlannedStartAt] = useState('');
const [loading, setLoading] = useState(true);
const [loadingOperators, setLoadingOperators] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
setLoading(true);
Promise.all([
apiRequest<{ data: Option[] }>('/inspection-visits/planning-context/departments'),
apiRequest<{ data: Inspector[] }>('/inspection-visits/assignees'),
requestJson<{ data: Option[] }>('/inspection-visits/planning-context/departments'),
requestJson<{ data: Inspector[] }>('/inspection-visits/assignees'),
]).then(([departmentResponse, inspectorResponse]) => {
setDepartments(departmentResponse.data);
setInspectors(inspectorResponse.data);
@@ -73,7 +87,7 @@ export function InspectionVisitCreateF61Page() {
setYacimientos([]);
setOperators([]);
if (!departmentId) return;
apiRequest<{ data: Option[] }>(`/inspection-visits/planning-context/departments/${departmentId}/areas`)
requestJson<{ data: Option[] }>(`/inspection-visits/planning-context/departments/${departmentId}/areas`)
.then((response) => setAreas(response.data))
.catch((cause) => setError(cause instanceof Error ? cause.message : String(cause)));
}, [departmentId]);
@@ -84,7 +98,7 @@ export function InspectionVisitCreateF61Page() {
setYacimientos([]);
setOperators([]);
if (!areaId) return;
apiRequest<{ data: Option[] }>(`/inspection-visits/planning-context/areas/${areaId}/yacimientos`)
requestJson<{ data: Option[] }>(`/inspection-visits/planning-context/areas/${areaId}/yacimientos`)
.then((response) => setYacimientos(response.data))
.catch((cause) => setError(cause instanceof Error ? cause.message : String(cause)));
}, [areaId]);
@@ -96,14 +110,12 @@ export function InspectionVisitCreateF61Page() {
const parsedStart = new Date(plannedStartAt);
if (Number.isNaN(parsedStart.getTime())) return;
const at = encodeURIComponent(parsedStart.toISOString());
setLoadingOperators(true);
apiRequest<{ data: Option[] }>(`/inspection-visits/planning-context/areas/${areaId}/operators?at=${at}`)
requestJson<{ data: Option[] }>(`/inspection-visits/planning-context/areas/${areaId}/operators?at=${at}`)
.then((response) => {
setOperators(response.data);
if (response.data.length === 1 && response.data[0]) setOperatorId(response.data[0].id);
})
.catch((cause) => setError(cause instanceof Error ? cause.message : String(cause)))
.finally(() => setLoadingOperators(false));
.catch((cause) => setError(cause instanceof Error ? cause.message : String(cause)));
}, [areaId, plannedStartAt]);
const submit = async (event: FormEvent) => {
@@ -121,7 +133,7 @@ export function InspectionVisitCreateF61Page() {
setBusy(true);
try {
const created = await apiRequest<CreatedInspection>('/inspection-visits', {
const created = await requestJson<CreatedInspection>('/inspection-visits', {
method: 'POST',
body: JSON.stringify({
operationalAreaId: areaId,
@@ -189,11 +201,10 @@ export function InspectionVisitCreateF61Page() {
<label className="field">
<span>Operadora del Área</span>
<SearchableSelect value={operatorId} onChange={(event) => setOperatorId(event.target.value)} searchPlaceholder="Buscar Operadora…" required disabled={!areaId || !plannedStartAt || loadingOperators || operators.length === 0}>
<option value="">{loadingOperators ? 'Cargando Operadora…' : operators.length === 0 && areaId && plannedStartAt ? 'Sin Operadora vigente para esa fecha' : 'Seleccionar Operadora…'}</option>
<SearchableSelect value={operatorId} onChange={(event) => setOperatorId(event.target.value)} searchPlaceholder="Buscar Operadora…" required disabled={!areaId || !plannedStartAt || operators.length === 0}>
<option value="">{operators.length === 0 && areaId && plannedStartAt ? 'Sin Operadora vigente para esa fecha' : 'Seleccionar Operadora…'}</option>
{operators.map((operator) => <option key={operator.id} value={operator.id}>{operator.name}</option>)}
</SearchableSelect>
<small className="muted">Se obtiene de la relación vigente del Área para la fecha y hora de inicio.</small>
</label>
<label className="field">
@@ -211,7 +222,7 @@ export function InspectionVisitCreateF61Page() {
</div>
{areaId && yacimientos.length === 0 && <Alert>Esta Área no tiene Yacimientos cargados. No se puede planificar una Inspección hasta corregir su jerarquía.</Alert>}
{areaId && plannedStartAt && !loadingOperators && operators.length === 0 && <Alert>Esta Área no tiene una Operadora vigente para la fecha seleccionada. Podés consultar el Inventario, pero no planificar una Inspección operativa hasta definir esa relación.</Alert>}
{areaId && plannedStartAt && operators.length === 0 && <Alert>Esta Área no tiene una Operadora vigente. Podés consultar el Inventario, pero no planificar una Inspección operativa hasta definir esa relación.</Alert>}
<div className="form-actions">
<Link className="button secondary" to="/inspecciones">Volver</Link>
@@ -11,18 +11,13 @@ import {
inspectionStatusClass,
inspectionVisitStatusLabel,
} from '../features/inspections/inspectionPresentation';
import {
listInspectionPreventiveCandidates,
} from '../features/inspections/preventiveCandidatesApi';
import type {
InspectionPreventiveCandidate,
} from '../features/inspections/preventiveCandidatesApi';
import {
createInspectionVisit,
excludeInspectionVisitAsset,
generateInspectionVisitChecklist,
getInspectionVisit,
includeInspectionVisitAsset,
listAssets,
listInspectionAssignees,
listInspectionPlanningAreas,
listInspectionPlanningOperators,
@@ -31,6 +26,7 @@ import {
updateInspectionVisit,
} from '../lib/api';
import type {
AssetListItem,
InspectionPerson,
InspectionPlanningContextAsset,
InspectionVisit,
@@ -104,7 +100,7 @@ export function InspectionVisitEditorF4Page() {
const [areas, setAreas] = useState<InspectionPlanningContextAsset[]>([]);
const [operators, setOperators] = useState<InspectionPlanningContextAsset[]>([]);
const [assignees, setAssignees] = useState<InspectionPerson[]>([]);
const [assets, setAssets] = useState<InspectionPreventiveCandidate[]>([]);
const [assets, setAssets] = useState<AssetListItem[]>([]);
const [assetSearch, setAssetSearch] = useState('');
const [newAssetId, setNewAssetId] = useState('');
const [leadInspectorId, setLeadInspectorId] = useState('');
@@ -113,8 +109,6 @@ export function InspectionVisitEditorF4Page() {
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [showCancelForm, setShowCancelForm] = useState(false);
const [cancelReason, setCancelReason] = useState('');
const applyVisit = (value: InspectionVisit, syncForm = true) => {
setVisit(value);
@@ -140,9 +134,9 @@ export function InspectionVisitEditorF4Page() {
};
useEffect(() => {
if (isNew) listInspectionPlanningAreas().then(setAreas).catch(() => undefined);
listInspectionPlanningAreas().then(setAreas).catch(() => undefined);
if (canAssign) listInspectionAssignees().then(setAssignees).catch(() => undefined);
}, [canAssign, isNew]);
}, [canAssign]);
useEffect(() => {
if (!isNew || form.operationalAreaId || !context.areaId) return;
@@ -162,27 +156,29 @@ export function InspectionVisitEditorF4Page() {
}, [id]);
useEffect(() => {
if (!isNew || !form.operationalAreaId) {
if (!form.operationalAreaId) {
setOperators([]);
return;
}
listInspectionPlanningOperators(form.operationalAreaId)
.then(setOperators)
.catch(() => setOperators([]));
}, [form.operationalAreaId, isNew]);
}, [form.operationalAreaId]);
useEffect(() => {
if (!id || !visit) {
if (!form.operationalAreaId || !form.operatorCompanyId) {
setAssets([]);
return;
}
const timer = window.setTimeout(() => {
listInspectionPreventiveCandidates(id, assetSearch)
.then(setAssets)
.catch((requestError) => setError(errorMessage(requestError)));
listAssets({
pageSize: 100,
search: assetSearch.trim(),
operationalAreaId: form.operationalAreaId,
}).then((response) => setAssets(response.data)).catch(() => undefined);
}, 220);
return () => window.clearTimeout(timer);
}, [id, assetSearch, visit?.updatedAt]);
}, [assetSearch, form.operationalAreaId, form.operatorCompanyId]);
const planningEditable = !visit || visit.status === 'DRAFT' || visit.status === 'PLANNED';
const activeAssetIds = useMemo(() => new Set(visit?.assets.map((asset) => asset.id) ?? []), [visit]);
@@ -211,10 +207,13 @@ export function InspectionVisitEditorF4Page() {
} else if (id) {
applyVisit(await updateInspectionVisit(id, {
objective: form.objective.trim() || null,
scopeAssetId: visit?.scopeAsset?.id ?? null,
operationalAreaId: form.operationalAreaId || null,
operatorCompanyId: form.operatorCompanyId || null,
plannedStartAt,
instructions: form.instructions.trim() || null,
}));
setSuccess('Planificación actualizada. Si cambió la fecha, regenerá el checklist.');
setSuccess('Planificación actualizada. Regenerá el checklist si cambió contexto o fecha.');
}
} catch (requestError) {
setError(errorMessage(requestError));
@@ -239,7 +238,6 @@ export function InspectionVisitEditorF4Page() {
setBusy(true); setError(''); setSuccess('');
try {
applyVisit(await replaceInspectionVisitAssets(id, [...activeAssetIds, newAssetId]), false);
setAssets((current) => current.filter((asset) => asset.id !== newAssetId));
setNewAssetId('');
setSuccess('Registro agregado como preventivo.');
} catch (requestError) { setError(errorMessage(requestError)); }
@@ -289,7 +287,7 @@ export function InspectionVisitEditorF4Page() {
finally { setBusy(false); }
};
const lifecycleAction = async (action: 'PLAN' | 'UNPLAN') => {
const lifecycleAction = async (action: 'PLAN' | 'UNPLAN' | 'CANCEL') => {
if (!id) return;
setBusy(true); setError(''); setSuccess('');
try {
@@ -297,34 +295,19 @@ export function InspectionVisitEditorF4Page() {
if (action === 'PLAN') {
updated = await planInspectionVisit(id);
setSuccess('Inspección planificada. Ya puede iniciarse desde la APK.');
} else {
} else if (action === 'UNPLAN') {
updated = await unplanInspectionVisit(id);
setSuccess('La Inspección volvió a borrador para corregir la planificación.');
}
applyVisit(updated);
} catch (requestError) { setError(errorMessage(requestError)); }
finally { setBusy(false); }
};
const cancelCurrentVisit = async (event: FormEvent) => {
event.preventDefault();
if (!id) return;
const reason = cancelReason.trim();
} else {
const reason = window.prompt('Indicá el motivo de cancelación (mínimo 10 caracteres):')?.trim() ?? '';
if (reason.length < 10) {
setError('El motivo de cancelación debe tener al menos 10 caracteres.');
if (reason) setError('El motivo debe tener al menos 10 caracteres.');
return;
}
if (reason.length > 500) {
setError('El motivo de cancelación no puede superar los 500 caracteres.');
return;
updated = await cancelInspectionVisit(id, reason);
setSuccess('Inspección cancelada con motivo auditado.');
}
setBusy(true); setError(''); setSuccess('');
try {
const updated = await cancelInspectionVisit(id, reason);
applyVisit(updated);
setCancelReason('');
setShowCancelForm(false);
setSuccess('Inspección cancelada. El motivo quedó registrado y auditado.');
} catch (requestError) { setError(errorMessage(requestError)); }
finally { setBusy(false); }
};
@@ -340,12 +323,12 @@ export function InspectionVisitEditorF4Page() {
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
<form className={`panel form-panel ${isNew ? 'inspection-quick-create' : ''}`} onSubmit={saveGeneral}>
<div className="panel-heading"><div><span className="eyebrow">{isNew ? 'CREACIÓN RÁPIDA' : 'PLANIFICACIÓN'}</span><h2>Contexto y fecha de inicio</h2><p className="section-copy">El Área, Yacimiento y Operadora quedan fijados al crear la Inspección. La fecha puede reprogramarse antes del trabajo de campo.</p></div>{visit && <small className="muted">Actualizado {formatDate(visit.updatedAt)}</small>}</div>
<div className="panel-heading"><div><span className="eyebrow">{isNew ? 'CREACIÓN RÁPIDA' : 'PLANIFICACIÓN'}</span><h2>Contexto y fecha de inicio</h2><p className="section-copy">No existe título independiente ni fecha de fin planificada. El cierre real se registra al terminar el trabajo de campo.</p></div>{visit && <small className="muted">Actualizado {formatDate(visit.updatedAt)}</small>}</div>
{visit && <div className="inspection-generated-code"><small>Identificador institucional</small><strong>{visit.code}</strong></div>}
<div className="form-grid">
{isNew ? <label className="field"><span>Área</span><SearchableSelect searchPlaceholder="Buscar Área…" value={form.operationalAreaId} onChange={(event) => { context.setAreaId(event.target.value); setForm((current) => ({ ...current, operationalAreaId: event.target.value, operatorCompanyId: '' })); }} required disabled={!canManage || !planningEditable}><option value="">Seleccionar Área</option>{areas.map((area) => <option key={area.id} value={area.id}>{area.name} · {area.code}</option>)}</SearchableSelect></label> : <label className="field"><span>Área</span><input value={visit?.operationalArea ? `${visit.operationalArea.name} · ${visit.operationalArea.code}` : 'Sin Área'} readOnly aria-readonly="true" /></label>}
{!isNew && <label className="field"><span>Yacimiento</span><input value={visit?.scopeAsset ? `${visit.scopeAsset.name} · ${visit.scopeAsset.code}` : 'Sin Yacimiento'} readOnly aria-readonly="true" /></label>}
{isNew ? <label className="field"><span>Operadora</span><SearchableSelect searchPlaceholder="Buscar Operadora…" value={form.operatorCompanyId} onChange={(event) => { context.setCompanyId(event.target.value); setForm((current) => ({ ...current, operatorCompanyId: event.target.value })); }} required disabled={!canManage || !planningEditable || !form.operationalAreaId}><option value="">Seleccionar Operadora</option>{operators.map((operator) => <option key={operator.id} value={operator.id}>{operator.name} · {operator.code}</option>)}</SearchableSelect></label> : <label className="field"><span>Operadora</span><input value={visit?.operatorCompany ? `${visit.operatorCompany.name} · ${visit.operatorCompany.code}` : 'Sin Operadora'} readOnly aria-readonly="true" /></label>}
<label className="field"><span>Área</span><SearchableSelect searchPlaceholder="Buscar Área…" value={form.operationalAreaId} onChange={(event) => { context.setAreaId(event.target.value); setForm((current) => ({ ...current, operationalAreaId: event.target.value, operatorCompanyId: '' })); }} required disabled={!canManage || !planningEditable}><option value="">Seleccionar Área</option>{areas.map((area) => <option key={area.id} value={area.id}>{area.name} · {area.code}</option>)}</SearchableSelect></label>
{!isNew && <label className="field"><span>Yacimiento</span><input value={visit?.scopeAsset?.name ?? 'Sin Yacimiento'} readOnly aria-readonly="true" /></label>}
<label className="field"><span>Operadora</span><SearchableSelect searchPlaceholder="Buscar Operadora…" value={form.operatorCompanyId} onChange={(event) => { context.setCompanyId(event.target.value); setForm((current) => ({ ...current, operatorCompanyId: event.target.value })); }} required disabled={!canManage || !planningEditable || !form.operationalAreaId}><option value="">Seleccionar Operadora</option>{operators.map((operator) => <option key={operator.id} value={operator.id}>{operator.name} · {operator.code}</option>)}</SearchableSelect></label>
<label className="field"><span>Fecha y hora de inicio</span><input type="datetime-local" value={form.plannedStartAt} onChange={(event) => setForm((current) => ({ ...current, plannedStartAt: event.target.value }))} required disabled={!canManage || !planningEditable} /></label>
{isNew && <label className="field"><span>Inspector responsable</span><SearchableSelect searchPlaceholder="Buscar Inspector…" value={leadInspectorId} onChange={(event) => setLeadInspectorId(event.target.value)} required disabled={!canAssign}><option value="">Seleccionar Inspector</option>{assignees.map((person) => <option key={person.id} value={person.id}>{personName(person)} · {person.username}</option>)}</SearchableSelect></label>}
</div>
@@ -354,15 +337,15 @@ export function InspectionVisitEditorF4Page() {
</form>
{visit && <article className="panel inspection-checklist-panel">
<div className="panel-heading"><div><span className="eyebrow">CHECKLIST TÉCNICO</span><h2>Antecedentes y controles</h2><p className="section-copy">Se construye para el Área, Yacimiento y Operadora fijados en la Inspección, tomando la fecha planificada. Las respuestas administrativas de empresa no modifican este checklist.</p></div>{canManage && planningEditable && <button type="button" className="button secondary" onClick={() => void regenerateChecklist()} disabled={busy}>Regenerar checklist</button>}</div>
{visit.checklist.stale && <Alert>La fecha cambió. Regenerá el checklist antes de planificar.</Alert>}
<div className="panel-heading"><div><span className="eyebrow">CHECKLIST TÉCNICO</span><h2>Antecedentes y controles</h2><p className="section-copy">Se construye por Área, Operadora y fecha. Las respuestas administrativas de empresa no modifican este checklist.</p></div>{canManage && planningEditable && <button type="button" className="button secondary" onClick={() => void regenerateChecklist()} disabled={busy}>Regenerar checklist</button>}</div>
{visit.checklist.stale && <Alert>El contexto o la fecha cambió. Regenerá el checklist antes de planificar.</Alert>}
<div className="inspection-checklist-metrics"><div><strong>{visit.checklist.verificationOverdue}</strong><span>controles vencidos</span></div><div><strong>{visit.checklist.upcomingControls}</strong><span>próximos 30 días</span></div><div><strong>{visit.checklist.antecedents}</strong><span>antecedentes</span></div><div><strong>{visit.checklist.actionableAssets}</strong><span>registros sugeridos</span></div></div>
{visit.checklist.items.length === 0 ? <EmptyState title="Sin antecedentes" text="No hay Hallazgos históricos técnicos para este contexto." /> : <div className="table-scroll"><table><thead><tr><th>Tipo</th><th>Hallazgo</th><th>Inventario</th><th>Fecha</th><th>Gravedad</th></tr></thead><tbody>{visit.checklist.items.map((item) => <tr key={item.id}><td><span className={`status-badge ${item.itemKind === 'ANTECEDENT' ? '' : 'warning'}`}>{checklistLabel(item.itemKind)}</span></td><td><Link className="history-asset-link" to={`/hallazgos/${item.findingId}`}><strong>{item.findingTitle}</strong><small>{item.findingCode}</small></Link></td><td><Link className="history-asset-link" to={`/inventarios/${item.asset.id}`}><strong>{item.asset.name}</strong><small>{item.asset.code} · {item.asset.typeName}</small></Link></td><td>{formatDateOnly(item.referenceOn)}</td><td>{item.severity ?? '—'}</td></tr>)}</tbody></table></div>}
</article>}
{visit && visit.verificationFindings.length > 0 && <article className="panel verification-visit-findings"><div className="panel-heading"><div><span className="eyebrow">VERIFICACIÓN</span><h2>Hallazgos a controlar</h2></div><span className="count-pill">{visit.verificationFindings.length}</span></div><div className="dossier-link-list">{visit.verificationFindings.map((finding) => <Link key={finding.id} to={`/hallazgos/${finding.id}`}><div><strong>{finding.title}</strong><small>{finding.code} · {finding.assetName} · objetivo {formatDateOnly(finding.targetControlOn ?? finding.nextControlOn)}</small>{finding.resultNotes && <small>{finding.resultNotes}</small>}</div><span>{finding.outcome === 'RESOLVED' ? 'Solucionado' : finding.outcome === 'NOT_RESOLVED' ? 'No solucionado' : finding.outcome === 'REQUIRES_NEW_DATE' ? 'Reprogramar' : 'Pendiente'}</span><Icon name="chevron" size={16} /></Link>)}</div></article>}
{visit && planningEditable && canManage && <form className="panel survey-add-target" onSubmit={addPreventiveAsset}><div className="panel-heading"><div><span className="eyebrow">PREVENTIVO</span><h2>Agregar Inventario sin pendiente previo</h2><p className="section-copy">Sólo se muestran Instalaciones y Subinstalaciones pertenecientes al Yacimiento fijado para esta Inspección.</p></div></div><label className="field"><span>Buscar Inventario</span><input value={assetSearch} onChange={(event) => setAssetSearch(event.target.value)} placeholder="Código o nombre" /></label><label className="field"><span>Inventario</span><SearchableSelect value={newAssetId} onChange={(event) => setNewAssetId(event.target.value)} required><option value="">Seleccionar</option>{candidateAssets.map((asset) => <option key={asset.id} value={asset.id}>{asset.code} · {asset.name} · {asset.typeName}</option>)}</SearchableSelect></label><div className="form-actions"><button className="button primary" disabled={busy || !newAssetId}><Icon name="plus" />Agregar preventivo</button></div></form>}
{visit && planningEditable && canManage && <form className="panel survey-add-target" onSubmit={addPreventiveAsset}><div className="panel-heading"><div><span className="eyebrow">PREVENTIVO</span><h2>Agregar Inventario sin pendiente previo</h2></div></div><label className="field"><span>Buscar Inventario</span><input value={assetSearch} onChange={(event) => setAssetSearch(event.target.value)} placeholder="Código o nombre" /></label><label className="field"><span>Inventario</span><SearchableSelect value={newAssetId} onChange={(event) => setNewAssetId(event.target.value)} required><option value="">Seleccionar</option>{candidateAssets.map((asset) => <option key={asset.id} value={asset.id}>{asset.code} · {asset.name} · {asset.type.name}</option>)}</SearchableSelect></label><div className="form-actions"><button className="button primary" disabled={busy || !newAssetId}><Icon name="plus" />Agregar preventivo</button></div></form>}
{visit && <article className="table-panel inspection-assets"><div className="table-summary"><strong>{visit.assets.length} incluidos</strong><span>{visit.checklist.excludedAssets} excluidos con trazabilidad</span></div>{visit.planningAssets.length === 0 ? <EmptyState title="Sin Inventarios" text="Generá el checklist o agregá un preventivo." /> : <div className="table-scroll"><table><thead><tr><th>Inventario</th><th>Origen</th><th>Estado</th><th>Motivo</th><th /></tr></thead><tbody>{visit.planningAssets.map((asset) => <tr key={asset.id}><td><Link className="history-asset-link" to={`/inventarios/${asset.id}`}><strong>{asset.name}</strong><small>{asset.code} · {asset.typeName}</small></Link></td><td>{sourceLabel(asset.planningSource)}</td><td>{asset.included ? 'Incluido' : 'Excluido'}</td><td>{asset.exclusionReason ?? '—'}</td><td>{canManage && planningEditable && (asset.included ? <button type="button" className="button danger-outline compact" onClick={() => void excludeAsset(asset.id)}>Excluir</button> : <button type="button" className="button secondary compact" onClick={() => void reincludeAsset(asset.id)}>Reincorporar</button>)}</td></tr>)}</tbody></table></div>}</article>}
@@ -370,9 +353,7 @@ export function InspectionVisitEditorF4Page() {
{visit && <InspectionActsPanel visit={visit} />}
{visit && <article className="panel survey-status-actions"><div><strong>Flujo de la Inspección</strong><p>La oficina planifica. El inicio y cierre operativo se realizan desde la APK. Una Inspección no puede cerrarse hasta que todas sus Actas estén selladas.</p></div><div className="form-actions">{canManage && visit.status === 'DRAFT' && <button type="button" className="button primary" disabled={busy} onClick={() => void lifecycleAction('PLAN')}>Confirmar planificación</button>}{canManage && visit.status === 'PLANNED' && <button type="button" className="button secondary" disabled={busy} onClick={() => void lifecycleAction('UNPLAN')}>Volver a borrador</button>}{canManage && ['DRAFT', 'PLANNED', 'IN_PROGRESS'].includes(visit.status) && <button type="button" className="button danger-outline" disabled={busy} onClick={() => { setError(''); setSuccess(''); setShowCancelForm(true); }}>Cancelar inspección</button>}</div></article>}
{visit && showCancelForm && ['DRAFT', 'PLANNED', 'IN_PROGRESS'].includes(visit.status) && <form className="panel form-panel" onSubmit={cancelCurrentVisit}><div className="panel-heading"><div><span className="eyebrow">CANCELACIÓN</span><h2>Cancelar inspección</h2><p className="section-copy">La cancelación es definitiva y queda auditada. No se permite si la Inspección ya tiene un Acta sellada.</p></div></div><label className="field"><span>Motivo de cancelación</span><textarea value={cancelReason} onChange={(event) => setCancelReason(event.target.value)} minLength={10} maxLength={500} rows={4} placeholder="Explicá el motivo de la cancelación…" required /><small className="muted">{cancelReason.trim().length}/500 · mínimo 10 caracteres</small></label><div className="form-actions"><button type="button" className="button secondary" disabled={busy} onClick={() => { setShowCancelForm(false); setCancelReason(''); }}>Volver</button><button type="submit" className="button danger-outline" disabled={busy || cancelReason.trim().length < 10 || cancelReason.trim().length > 500}>{busy ? 'Cancelando…' : 'Confirmar cancelación'}</button></div></form>}
{visit && <article className="panel survey-status-actions"><div><strong>Flujo de la Inspección</strong><p>La oficina planifica. El inicio y cierre operativo se realizan desde la APK. Una Inspección no puede cerrarse hasta que todas sus Actas estén selladas.</p></div><div className="form-actions">{canManage && visit.status === 'DRAFT' && <button type="button" className="button primary" disabled={busy} onClick={() => void lifecycleAction('PLAN')}>Confirmar planificación</button>}{canManage && visit.status === 'PLANNED' && <button type="button" className="button secondary" disabled={busy} onClick={() => void lifecycleAction('UNPLAN')}>Volver a borrador</button>}{canManage && ['DRAFT', 'PLANNED'].includes(visit.status) && <button type="button" className="button danger-outline" disabled={busy} onClick={() => void lifecycleAction('CANCEL')}>Cancelar</button>}</div></article>}
{visit?.status === 'PLANNED' && <div className="temporal-notice"><Icon name="clipboard" /><p><strong>Planificación lista.</strong> El Inspector asignado debe iniciar la Inspección desde la APK.</p></div>}
{visit?.status === 'IN_PROGRESS' && <div className="temporal-notice"><Icon name="clipboard" /><p><strong>Inspección en curso.</strong> Actas, Hallazgos, firmas y cierre se registran desde la APK.</p></div>}
+6 -17
View File
@@ -44,8 +44,6 @@ export function InventoryCreatePage() {
const [parentSearch, setParentSearch] = useState('');
const [parentId, setParentId] = useState('');
const [familyId, setFamilyId] = useState('');
const [operatorCompanyId, setOperatorCompanyId] = useState('');
const [concessionTypeId, setConcessionTypeId] = useState('');
const [familyFindings, setFamilyFindings] = useState<InventoryFamilyFindings | null>(null);
const [name, setName] = useState('');
const [code, setCode] = useState('');
@@ -68,7 +66,6 @@ export function InventoryCreatePage() {
const requiresParent = Boolean(parentLabelByKind[kind]);
const requiresFamily = kind === 'INSTALACION' || kind === 'SUBINSTALACION';
const requiresYacimientoContext = kind === 'YACIMIENTO';
const parentLabel = parentLabelByKind[kind] ?? '';
useEffect(() => {
@@ -102,25 +99,18 @@ export function InventoryCreatePage() {
if (!requiresFamily) setFamilyId('');
if (kind==='SUBINSTALACION' && familyId && !families.some((item)=>item.id===familyId)) setFamilyId('');
},[kind,requiresFamily,families,familyId]);
useEffect(() => {
if (!requiresYacimientoContext) { setOperatorCompanyId(''); setConcessionTypeId(''); }
},[requiresYacimientoContext]);
const changeKind=(next:InventoryStructureKind) => {
setKind(next); setParentId(''); setParentSearch(''); setFamilyId(''); setFamilyFindings(null);
setOperatorCompanyId(''); setConcessionTypeId(''); setError('');
setKind(next); setParentId(''); setParentSearch(''); setFamilyId(''); setFamilyFindings(null); setError('');
};
const save=async(event:FormEvent) => {
event.preventDefault();
if (requiresParent && !parentId) { setError(`Seleccioná ${parentLabel}.`); return; }
if (requiresYacimientoContext && !operatorCompanyId) { setError('Seleccioná la Empresa relacionada del Yacimiento.'); return; }
if (requiresYacimientoContext && !concessionTypeId) { setError('Seleccioná el Tipo de concesión del Yacimiento.'); return; }
if (requiresFamily && !familyId) { setError(`Seleccioná el tipo de ${kindLabel(kind).toLowerCase()}.`); return; }
setSaving(true); setError('');
try {
const created=await createInventoryStructure({
kind,name:name.trim(),parentId:parentId || null,familyId:familyId || null,
operatorCompanyId:operatorCompanyId || null,concessionTypeId:concessionTypeId || null,
code:code.trim() || null,commonName:commonName.trim() || null,description:description.trim() || null,
});
navigate(`/inventarios/${created.id}`,{replace:true});
@@ -131,15 +121,14 @@ export function InventoryCreatePage() {
if (loading) return <LoadingBlock label="Preparando alta…" />;
return <section className="narrow-section asset-detail-page">
<nav className="breadcrumb asset-detail-breadcrumb" aria-label="Ruta del Inventario"><Link to="/inventarios">Inventarios</Link><span></span><strong>Nuevo registro</strong></nav>
<div className="page-heading asset-editor-heading"><div><span className="eyebrow">CARGA MANUAL</span><h1>Nuevo registro</h1><p>Modelo fijo: Departamento Área Yacimiento Instalación Subinstalación. La Empresa y el Tipo de concesión pertenecen al Yacimiento.</p></div></div>
<div className="page-heading asset-editor-heading"><div><span className="eyebrow">CARGA MANUAL</span><h1>Nuevo registro</h1><p>Para crear sólo necesitás ubicación, clasificación cuando corresponda y nombre. DH genera el código automáticamente.</p></div></div>
{error && <Alert>{error}</Alert>}
<form className="panel form-panel" onSubmit={save}>
<div className="form-section"><div><h2>1. ¿Qué querés crear?</h2><p className="section-copy">Área sólo depende de Departamento. Yacimiento define Área, Empresa relacionada y Tipo de concesión.</p></div><label className="field"><span>Tipo</span><select value={kind} onChange={(event)=>changeKind(event.target.value as InventoryStructureKind)}>{KINDS.map((item)=><option key={item.kind} value={item.kind}>{item.label}</option>)}</select></label></div>
<div className="form-section"><div><h2>1. ¿Qué querés crear?</h2><p className="section-copy">Departamento Área Yacimiento Instalación Subinstalación. Empresa queda fuera del árbol físico.</p></div><label className="field"><span>Tipo</span><select value={kind} onChange={(event)=>changeKind(event.target.value as InventoryStructureKind)}>{KINDS.map((item)=><option key={item.kind} value={item.kind}>{item.label}</option>)}</select></label></div>
{requiresParent && <div className="form-section"><div><h2>2. Ubicación</h2><p className="section-copy">Elegí el {parentLabel.toLowerCase()} concreto.</p></div><label className="field"><span>Buscar {parentLabel.toLowerCase()}</span><input value={parentSearch} onChange={(event)=>setParentSearch(event.target.value)} placeholder={`Buscar ${parentLabel.toLowerCase()}`} /></label><label className="field"><span>{parentLabel} <em>obligatorio</em></span><select value={parentId} onChange={(event)=>{setParentId(event.target.value);setFamilyId('');}} required><option value="">Seleccionar</option>{parents.map((parent)=><option key={parent.id} value={parent.id}>{parent.name} · {parent.code}</option>)}</select></label></div>}
{requiresYacimientoContext && <div className="form-section"><div><h2>3. Contexto del Yacimiento</h2><p className="section-copy">Estas relaciones pertenecen directamente al Yacimiento y no al Área.</p></div><div className="form-grid"><label className="field"><span>Empresa relacionada <em>obligatorio</em></span><select value={operatorCompanyId} onChange={(event)=>setOperatorCompanyId(event.target.value)} required><option value="">Seleccionar</option>{options?.companies.map((company)=><option key={company.id} value={company.id}>{company.name}</option>)}</select></label><label className="field"><span>Tipo de concesión <em>obligatorio</em></span><select value={concessionTypeId} onChange={(event)=>setConcessionTypeId(event.target.value)} required><option value="">Seleccionar</option>{options?.concessionTypes.map((type)=><option key={type.id} value={type.id}>{type.name}</option>)}</select></label></div></div>}
{requiresFamily && <div className="form-section"><div><h2>3. Clasificación técnica</h2><p className="section-copy">Define los datos técnicos y Hallazgos aplicables. En Subinstalaciones sólo aparecen clasificaciones compatibles con la Instalación elegida.</p></div>{kind==='SUBINSTALACION' && !selectedParent?.inventoryFamily ? <Alert>La Instalación elegida todavía no tiene clasificación técnica.</Alert> : <label className="field"><span>Tipo de {kindLabel(kind).toLowerCase()} <em>obligatorio</em></span><select value={familyId} onChange={(event)=>setFamilyId(event.target.value)} required><option value="">Seleccionar</option>{families.map((family)=><option key={family.id} value={family.id}>{family.name}</option>)}</select></label>}{kind==='SUBINSTALACION' && selectedParent?.inventoryFamily && families.length===0 && <Alert>No hay tipos de Subinstalación compatibles con {selectedParent.inventoryFamily.name}.</Alert>}{selectedFamily && <div className="context-create-banner"><Icon name="alert" /><div><strong>{selectedFamily.name}</strong><span>{familyFindings ? `${familyFindings.count} Hallazgo${familyFindings.count===1?'':'s'} asociado${familyFindings.count===1?'':'s'}` : 'Cargando Hallazgos asociados…'}</span>{familyFindings && familyFindings.items.length>0 && <ul style={{margin:'8px 0 0',paddingLeft:18}}>{familyFindings.items.slice(0,6).map((item)=><li key={item.id}>{item.title}</li>)}{familyFindings.items.length>6 && <li>+ {familyFindings.items.length-6} más</li>}</ul>}</div></div>}</div>}
<div className="form-section"><div><h2>{requiresYacimientoContext ? '4' : requiresFamily ? '4' : requiresParent ? '3' : '2'}. Nombre</h2><p className="section-copy">El nombre es el dato propio principal de este nivel. Los datos técnicos se completan después, cuando corresponda.</p></div><label className="field"><span>Nombre <em>obligatorio</em></span><input autoFocus value={name} onChange={(event)=>setName(event.target.value)} required maxLength={200} placeholder={`Nombre de ${kindLabel(kind).toLowerCase()}`} /></label><details style={{marginTop:12}}><summary style={{cursor:'pointer',fontWeight:700}}>Datos opcionales</summary><div style={{marginTop:14}}><div className="form-grid"><label className="field"><span>Código DH</span><input value={code} onChange={(event)=>setCode(event.target.value.toUpperCase())} maxLength={120} pattern="[A-Z0-9][A-Z0-9._/-]*" placeholder="Dejalo vacío para generar automáticamente" /></label><label className="field"><span>Nombre habitual</span><input value={commonName} onChange={(event)=>setCommonName(event.target.value)} maxLength={200} /></label></div><label className="field"><span>Descripción</span><textarea value={description} onChange={(event)=>setDescription(event.target.value)} rows={2} maxLength={4000} /></label></div></details></div>
<div className="form-actions"><Link className="button secondary" to="/inventarios">Cancelar</Link><button className="button primary" disabled={saving || !name.trim() || (requiresParent && !parentId) || (requiresYacimientoContext && (!operatorCompanyId || !concessionTypeId)) || (requiresFamily && !familyId)}><Icon name="check" />{saving?'Creando…':`Crear ${kindLabel(kind)}`}</button></div>
{requiresFamily && <div className="form-section"><div><h2>3. Clasificación técnica</h2><p className="section-copy">Define los datos técnicos y Hallazgos aplicables. En Subinstalaciones sólo aparecen clasificaciones compatibles con la Instalación elegida.</p></div>{kind==='SUBINSTALACION' && !selectedParent?.inventoryFamily ? <Alert>La Instalación elegida todavía no tiene clasificación técnica.</Alert> : <label className="field"><span>Tipo de {kindLabel(kind).toLowerCase()} <em>obligatorio</em></span><select value={familyId} onChange={(event)=>setFamilyId(event.target.value)} required><option value="">Seleccionar</option>{families.map((family)=><option key={family.id} value={family.id}>{family.name}</option>)}</select></label>}{kind==='SUBINSTALACION' && selectedParent?.inventoryFamily && families.length===0 && <Alert>No hay tipos de Subinstalación compatibles con {selectedParent.inventoryFamily.name}. Configuralos primero en Configuración de Inventarios.</Alert>}{selectedFamily && <div className="context-create-banner"><Icon name="alert" /><div><strong>{selectedFamily.name}</strong><span>{familyFindings ? `${familyFindings.count} Hallazgo${familyFindings.count===1?'':'s'} asociado${familyFindings.count===1?'':'s'}` : 'Cargando Hallazgos asociados…'}</span>{familyFindings && familyFindings.items.length>0 && <ul style={{margin:'8px 0 0',paddingLeft:18}}>{familyFindings.items.slice(0,6).map((item)=><li key={item.id}>{item.title}</li>)}{familyFindings.items.length>6 && <li>+ {familyFindings.items.length-6} más</li>}</ul>}</div></div>}</div>}
<div className="form-section"><div><h2>{requiresFamily ? '4' : requiresParent ? '3' : '2'}. Nombre</h2><p className="section-copy">Los datos técnicos se completan después, en la ficha del elemento.</p></div><label className="field"><span>Nombre <em>obligatorio</em></span><input autoFocus value={name} onChange={(event)=>setName(event.target.value)} required maxLength={200} placeholder={`Nombre de ${kindLabel(kind).toLowerCase()}`} /></label><details style={{marginTop:12}}><summary style={{cursor:'pointer',fontWeight:700}}>Datos opcionales</summary><div style={{marginTop:14}}><div className="form-grid"><label className="field"><span>Código DH</span><input value={code} onChange={(event)=>setCode(event.target.value.toUpperCase())} maxLength={120} pattern="[A-Z0-9][A-Z0-9._/-]*" placeholder="Dejalo vacío para generar automáticamente" /></label><label className="field"><span>Nombre habitual</span><input value={commonName} onChange={(event)=>setCommonName(event.target.value)} maxLength={200} /></label></div><label className="field"><span>Descripción</span><textarea value={description} onChange={(event)=>setDescription(event.target.value)} rows={2} maxLength={4000} /></label></div></details></div>
<div className="form-actions"><Link className="button secondary" to="/inventarios">Cancelar</Link><button className="button primary" disabled={saving || !name.trim() || (requiresParent && !parentId) || (requiresFamily && !familyId)}><Icon name="check" />{saving?'Creando…':`Crear ${kindLabel(kind)}`}</button></div>
</form>
</section>;
}
-51
View File
@@ -1,51 +0,0 @@
import { useEffect, useState } from 'react';
import { useParams, useSearchParams } from 'react-router';
import { useAuth } from '../auth/AuthContext';
import { hasAdministratorRole } from '../auth/adminAccess';
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
import { getAsset } from '../lib/api';
import type { AssetDetail } from '../lib/api';
import { AssetEditorPage } from './AssetEditorPage';
import { CompanyInventoryPage } from './CompanyInventoryPage';
import { TerritorialInventoryPage, isTerritorialInventoryAsset } from './TerritorialInventoryPage';
export function InventoryDetailPage() {
const { id } = useParams();
const [params] = useSearchParams();
const { user } = useAuth();
const [asset, setAsset] = useState<AssetDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
if (!id) {
setError('No se encontró el registro solicitado.');
setLoading(false);
return;
}
setLoading(true);
setError('');
getAsset(id)
.then(setAsset)
.catch((requestError) => setError(errorMessage(requestError)))
.finally(() => setLoading(false));
}, [id]);
if (loading) return <LoadingBlock label="Cargando registro…" />;
if (!asset) return <Alert>{error || 'No se pudo cargar el registro.'}</Alert>;
const advancedRequested = params.get('advanced') === '1';
const advancedAllowed = hasAdministratorRole(user);
const advanced = advancedRequested && advancedAllowed;
const isCompany = asset.type.code.toLowerCase() === 'empresa';
if (isCompany && !advanced) {
return <CompanyInventoryPage initialAsset={asset} />;
}
if (isTerritorialInventoryAsset(asset) && !advanced) {
return <TerritorialInventoryPage initialAsset={asset} />;
}
return <AssetEditorPage />;
}
+1 -4
View File
@@ -2,7 +2,6 @@ import { useEffect, useState } from 'react';
import type { FormEvent } from 'react';
import { Link, useParams } from 'react-router';
import { useAuth } from '../auth/AuthContext';
import { DocumentPdfProjection } from '../components/DocumentPdfProjection';
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
import { Icon } from '../components/Icon';
import { SearchableSelect } from '../components/SearchableSelect';
@@ -194,7 +193,7 @@ export function ReportDetailPage() {
{success && <Alert type="success">{success}</Alert>}
{report && <>
<div className="temporal-notice"><Icon name="clipboard" /><p><strong>Un INF corresponde a una sola Acta.</strong> Una Inspección puede contener varias Actas y, por lo tanto, varios INF independientes. El Word puede editarse durante la preparación; el Acta sellada y sus Hallazgos permanecen inmutables.</p></div>
<div className="temporal-notice"><Icon name="clipboard" /><p><strong>Un INF corresponde a una sola Acta.</strong> El Word puede editarse durante la preparación del Informe. El Acta sellada y los Hallazgos que contiene permanecen inmutables.</p></div>
<section className="panel report-summary-panel">
<div className="panel-heading"><div><span className="eyebrow">TRAZABILIDAD DOCUMENTAL</span><h2>{report.code}</h2></div><small className="muted">Versión del Acta: {report.actVersion}</small></div>
@@ -211,8 +210,6 @@ export function ReportDetailPage() {
</div>
</section>
<DocumentPdfProjection kind="report" />
<section className="panel">
<div className="panel-heading"><div><span className="eyebrow">WORD EDITABLE</span><h2>Preparación del INF</h2><p className="section-copy">El Inspector puede revisar y ajustar el texto del Informe antes de incorporarlo a GEDO. Esta edición no altera el Acta fuente.</p></div>{report.wordStatus === 'READY' && <a className="button secondary" href={inspectionReportWordDownloadUrl(report.id)}>Descargar Word</a>}</div>
<form className="form-section" onSubmit={saveNarrative}>
@@ -1,276 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import type { FormEvent } from 'react';
import { Link, useSearchParams } from 'react-router';
import { useAuth } from '../auth/AuthContext';
import { hasAdministratorRole } from '../auth/adminAccess';
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
import { Icon } from '../components/Icon';
import { AssetHistoryPanel } from '../features/assets/AssetHistoryPanel';
import { assetStatusClass, assetStatusLabel } from '../features/assets/assetPresentation';
import {
getAssetLineage,
listAreaCompanyRelations,
listAssetTreeChildren,
listInspectionVisits,
updateAsset,
} from '../lib/api';
import type { AreaCompanyRelation, AssetDetail, AssetLineageItem, AssetListItem } from '../lib/api';
import {
listInspectionActsGlobalF4,
type InspectionActStatusF4,
} from '../lib/inspectionActF4Api';
import { listInspectionReportsF4 } from '../lib/reportWorkflowApi';
import './territorialInventory.css';
type TerritoryTab = 'summary' | 'history';
function normalized(value: string) {
return value
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, ' ')
.trim();
}
function tokensForType(type: { code: string; name: string }) {
return new Set(`${normalized(type.code)} ${normalized(type.name)}`.split(' ').filter(Boolean));
}
function typeTokens(asset: AssetDetail) {
return tokensForType(asset.type);
}
export function isTerritorialInventoryAsset(asset: AssetDetail) {
const tokens = typeTokens(asset);
return tokens.has('departamento') || tokens.has('area') || tokens.has('yacimiento');
}
function isOperationalArea(asset: AssetDetail) {
return typeTokens(asset).has('area');
}
function isYacimiento(asset: AssetDetail) {
return typeTokens(asset).has('yacimiento');
}
function lineageArea(lineage: AssetLineageItem[]) {
return [...lineage].reverse().find((item) => tokensForType(item.type).has('area')) ?? null;
}
function relationRoleLabel(role: AreaCompanyRelation['relationRole']) {
if (role === 'OPERATOR') return 'Operadora';
if (role === 'TECHNICAL_OPERATOR') return 'Operadora técnica';
if (role === 'CONCESSIONAIRE') return 'Concesionaria / titular';
if (role === 'PERMIT_HOLDER') return 'Permisionaria';
if (role === 'PARTICIPANT') return 'Participante';
return 'Vinculada';
}
export function TerritorialInventoryPage({ initialAsset }: { initialAsset: AssetDetail }) {
const [params, setParams] = useSearchParams();
const requestedTab = params.get('tab') as TerritoryTab | null;
const tab: TerritoryTab = requestedTab === 'history' ? 'history' : 'summary';
const { user, hasPermission } = useAuth();
const canEdit = hasPermission('assets.update');
const canCreate = hasPermission('assets.create');
const canReadHistory = hasPermission('assets.read_history');
const canReadRelations = hasPermission('asset_relations.read');
const canReadActs = hasPermission('inspection_acts.read');
const canReadReports = hasPermission('inspection_reports.read');
const canReadInspections = hasPermission('inspections.read');
const canAdvanced = hasAdministratorRole(user);
const [asset, setAsset] = useState(initialAsset);
const [children, setChildren] = useState<AssetListItem[]>([]);
const [childrenHasMore, setChildrenHasMore] = useState(false);
const [lineage, setLineage] = useState<AssetLineageItem[]>([]);
const [relations, setRelations] = useState<AreaCompanyRelation[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [editing, setEditing] = useState(false);
const [saving, setSaving] = useState(false);
const [name, setName] = useState(initialAsset.name);
const [description, setDescription] = useState(initialAsset.description ?? '');
const [metrics, setMetrics] = useState({ activeInspections: 0, acts: 0, reports: 0 });
const area = isOperationalArea(asset);
const yacimiento = isYacimiento(asset);
const activeRelations = useMemo(() => relations.filter((item) => item.active), [relations]);
const relationAreaContext = useMemo(() => {
if (area) return { id: asset.id, code: asset.code, name: asset.name };
if (asset.operationalArea) return asset.operationalArea;
const ancestor = lineageArea(lineage);
return ancestor ? { id: ancestor.id, code: ancestor.code, name: ancestor.name } : null;
}, [area, asset.id, asset.code, asset.name, asset.operationalArea, lineage]);
const loadSummary = async () => {
setLoading(true);
setError('');
try {
const loadedLineage = await getAssetLineage(asset.id);
const ancestorArea = lineageArea(loadedLineage);
const relationAreaId = area
? asset.id
: asset.operationalArea?.id ?? ancestorArea?.id ?? null;
const [childPage, loadedRelations] = await Promise.all([
listAssetTreeChildren({ parentId: asset.id, limit: 100 }),
relationAreaId && canReadRelations
? listAreaCompanyRelations({ areaId: relationAreaId, includeHistory: true })
: Promise.resolve([]),
]);
setLineage(loadedLineage);
setChildren(childPage.data);
setChildrenHasMore(childPage.meta.hasMore);
setRelations(loadedRelations);
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setLoading(false);
}
};
useEffect(() => { void loadSummary(); }, [asset.id, area, canReadRelations]);
useEffect(() => {
if (!area) {
setMetrics({ activeInspections: 0, acts: 0, reports: 0 });
return;
}
let cancelled = false;
const loadMetrics = async () => {
try {
const actStatuses: InspectionActStatusF4[] = ['SEALED', 'CLOSED', 'RECTIFIED'];
const [planned, inProgress, actPages, reportPage] = await Promise.all([
canReadInspections ? listInspectionVisits({ areaId: asset.id, status: 'PLANNED', pageSize: 1 }) : Promise.resolve(null),
canReadInspections ? listInspectionVisits({ areaId: asset.id, status: 'IN_PROGRESS', pageSize: 1 }) : Promise.resolve(null),
canReadActs
? Promise.all(actStatuses.map((status) => listInspectionActsGlobalF4({ areaId: asset.id, status, pageSize: 1 })))
: Promise.resolve([]),
canReadReports ? listInspectionReportsF4({ areaId: asset.id, pageSize: 1 }) : Promise.resolve(null),
]);
if (cancelled) return;
setMetrics({
activeInspections: (planned?.meta.total ?? 0) + (inProgress?.meta.total ?? 0),
acts: actPages.reduce((total, page) => total + page.meta.total, 0),
reports: reportPage?.meta.total ?? 0,
});
} catch {
if (!cancelled) setMetrics({ activeInspections: 0, acts: 0, reports: 0 });
}
};
void loadMetrics();
return () => { cancelled = true; };
}, [asset.id, area, canReadActs, canReadReports, canReadInspections]);
const save = async (event: FormEvent) => {
event.preventDefault();
if (!canEdit || !name.trim()) return;
setSaving(true); setError(''); setSuccess('');
try {
const saved = await updateAsset(asset.id, {
name: name.trim(),
description: description.trim() || null,
});
setAsset(saved);
setName(saved.name);
setDescription(saved.description ?? '');
setEditing(false);
setSuccess('Datos actualizados.');
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setSaving(false);
}
};
const setTab = (next: TerritoryTab) => {
const nextParams = new URLSearchParams(params);
next === 'summary' ? nextParams.delete('tab') : nextParams.set('tab', next);
nextParams.delete('advanced');
setParams(nextParams);
};
return <section className="narrow-section territory-page">
<nav className="breadcrumb territory-breadcrumb" aria-label="Ruta territorial">
<Link to="/inventarios">Inventarios</Link><span></span>
<Link to="/inventarios?section=territory">Áreas y yacimientos</Link><span></span><strong>{asset.name}</strong>
</nav>
<header className="territory-hero">
<div>
<span className="eyebrow">{asset.type.name.toUpperCase()}</span>
<h1>{asset.name}</h1>
<p><strong>{asset.code}</strong></p>
</div>
<div className="territory-hero-actions">
<span className={`status-badge ${assetStatusClass(asset.informationStatus)}`}>{assetStatusLabel(asset.informationStatus)}</span>
{canEdit && tab === 'summary' && <button type="button" className="button secondary" onClick={() => setEditing((current) => !current)}><Icon name="edit" />{editing ? 'Cancelar edición' : 'Editar'}</button>}
{canCreate && tab === 'summary' && <Link className="button primary" to={`/inventarios/nuevo?parentId=${asset.id}`}><Icon name="plus" />Agregar elemento</Link>}
</div>
</header>
<div className="territory-purpose-note"><Icon name="map" /><span>Este nivel organiza el territorio y la navegación. Los hallazgos se cargan sobre elementos inspeccionables, no sobre {asset.type.name.toLowerCase()}s.</span></div>
<nav className="territory-tabs" aria-label="Secciones territoriales">
<button type="button" className={tab === 'summary' ? 'active' : ''} onClick={() => setTab('summary')}>Resumen</button>
{canReadHistory && <button type="button" className={tab === 'history' ? 'active' : ''} onClick={() => setTab('history')}>Historial</button>}
</nav>
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
{tab === 'summary' && <div className="territory-stack">
<article className="panel territory-card">
<div className="territory-card-heading">
<div><span className="eyebrow">DATOS BÁSICOS</span><h2>Identificación</h2></div>
</div>
{editing ? <form className="territory-edit-form" onSubmit={save}>
<label className="field"><span>Nombre</span><input value={name} onChange={(event) => setName(event.target.value)} required maxLength={200} /></label>
<label className="field"><span>Descripción <em>opcional</em></span><textarea value={description} onChange={(event) => setDescription(event.target.value)} rows={3} maxLength={4000} /></label>
<div className="form-actions"><button type="button" className="button secondary" onClick={() => { setEditing(false); setName(asset.name); setDescription(asset.description ?? ''); }}>Cancelar</button><button className="button primary" disabled={saving}><Icon name="check" />{saving ? 'Guardando…' : 'Guardar'}</button></div>
</form> : <div className="territory-data-grid">
<div><small>Tipo</small><strong>{asset.type.name}</strong></div>
<div><small>Código DH</small><strong>{asset.code}</strong></div>
{asset.description && <div className="wide"><small>Descripción</small><strong>{asset.description}</strong></div>}
</div>}
</article>
<article className="panel territory-card">
<div className="territory-card-heading">
<div><span className="eyebrow">CONTENIDO</span><h2>Registros dentro de {asset.name}</h2><p>Acceso directo a los niveles que dependen de este registro.</p></div>
<span className="count-pill">{children.length}{childrenHasMore ? '+' : ''}</span>
</div>
{loading ? <LoadingBlock label="Cargando contenido…" /> : children.length === 0 ? <div className="inline-empty">Todavía no hay registros dentro de {asset.name}.</div> : <div className="territory-child-list">
{children.map((child) => <Link to={`/inventarios/${child.id}`} key={child.id} className="territory-child-row">
<span className="asset-symbol"><Icon name="layers" /></span>
<span><strong>{child.name}</strong><small>{child.type.name} · {child.code}</small></span>
<span className={`status-badge ${assetStatusClass(child.informationStatus)}`}>{assetStatusLabel(child.informationStatus)}</span>
<Icon name="chevron" size={16} />
</Link>)}
</div>}
{childrenHasMore && <div className="territory-card-actions"><Link className="button secondary" to={`/inventarios?section=territory&parentId=${asset.id}`}>Ver contenido completo</Link></div>}
</article>
{relationAreaContext && canReadRelations && <article className="panel territory-card">
<div className="territory-card-heading"><div><span className="eyebrow">OPERACIÓN</span><h2>Empresas vinculadas</h2><p>{area ? 'Quién opera o participa actualmente en esta área.' : yacimiento ? `Empresas que operan o participan en el Área ${relationAreaContext.name}, contexto operativo de este yacimiento.` : `Empresas vinculadas al Área ${relationAreaContext.name}.`}</p></div><span className="count-pill">{activeRelations.length}</span></div>
{loading ? <LoadingBlock label="Cargando empresas…" /> : activeRelations.length === 0 ? <div className="inline-empty">No hay empresas con vínculo vigente en el Área {relationAreaContext.name}.</div> : <div className="territory-company-list">
{activeRelations.map((relation) => <Link to={`/inventarios/${relation.company.id}`} key={relation.id} className="territory-company-row"><span><strong>{relation.company.name}</strong><small>{relationRoleLabel(relation.relationRole)}{!area ? ` · ${relation.area.name}` : ''}</small></span><Icon name="chevron" size={16} /></Link>)}
</div>}
</article>}
{area && (canReadInspections || canReadActs || canReadReports) && <article className="panel territory-card">
<div className="territory-card-heading"><div><span className="eyebrow">ACTIVIDAD</span><h2>Actividad del área</h2><p>Resumen documental y operativo, sin mezclarlo con hallazgos de los elementos inspeccionados.</p></div></div>
<div className="territory-metrics">
{canReadInspections && <div><small>Inspecciones activas</small><strong>{metrics.activeInspections}</strong></div>}
{canReadActs && <div><small>Actas emitidas</small><strong>{metrics.acts}</strong></div>}
{canReadReports && <div><small>Informes</small><strong>{metrics.reports}</strong></div>}
</div>
</article>}
{canAdvanced && <div className="territory-advanced-link"><span>Los datos estructurales, procedencia, ubicación y opciones técnicas quedan fuera de la vista cotidiana.</span><Link to={`/inventarios/${asset.id}?advanced=1`}>Administración avanzada</Link></div>}
</div>}
{tab === 'history' && canReadHistory && <AssetHistoryPanel assetId={asset.id} />}
</section>;
}
-105
View File
@@ -1,105 +0,0 @@
.company-page { display: grid; gap: 18px; }
.company-breadcrumb { margin-bottom: 0; }
.company-hero {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 24px;
padding: 4px 0 2px;
}
.company-hero h1 { margin: 6px 0 5px; font-size: clamp(31px, 3.5vw, 44px); line-height: 1.02; letter-spacing: -.045em; }
.company-hero p { margin: 0; color: var(--muted); font-size: 13px; }
.company-hero-actions { display: flex; align-items: center; gap: 12px; }
.company-tabs {
display: flex;
align-items: center;
gap: 4px;
overflow-x: auto;
padding: 0 0 1px;
border-bottom: 1px solid var(--line);
}
.company-tabs button {
position: relative;
min-height: 43px;
padding: 9px 13px;
border: 0;
color: #566178;
background: transparent;
cursor: pointer;
white-space: nowrap;
font-size: 12px;
font-weight: 700;
}
.company-tabs button:hover { color: var(--ink); }
.company-tabs button.active { color: var(--blue); }
.company-tabs button.active::after { content: ''; position: absolute; left: 10px; right: 10px; bottom: -1px; height: 2px; border-radius: 2px; background: var(--blue); }
.company-stack { display: grid; gap: 15px; }
.company-card { padding: 23px; }
.company-card-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; margin-bottom: 18px; }
.company-card-heading h2 { margin: 4px 0 4px; }
.company-card-heading p { max-width: 680px; margin: 0; color: var(--muted); font-size: 12px; line-height: 1.5; }
.company-card-actions { display: flex; justify-content: flex-end; margin-top: 17px; }
.company-data-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1px; overflow: hidden; border: 1px solid var(--line); border-radius: 11px; background: var(--line); }
.company-data-grid > div { min-height: 78px; padding: 15px 16px; background: #fff; }
.company-data-grid small, .company-data-grid strong { display: block; }
.company-data-grid small { margin-bottom: 7px; color: var(--muted); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; }
.company-data-grid strong { font-size: 13px; font-weight: 750; }
.company-edit-form { display: grid; gap: 16px; }
.company-area-list { display: grid; gap: 8px; }
.company-area-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; min-height: 62px; padding: 12px 14px; border: 1px solid #e3e7ee; border-radius: 10px; background: #fafbfc; }
.company-area-row > a { min-width: 0; text-decoration: none; }
.company-area-row > a:hover strong { color: var(--blue); }
.company-area-row strong, .company-area-row small { display: block; }
.company-area-row strong { font-size: 13px; }
.company-area-row small { margin-top: 4px; color: var(--muted); font-size: 10px; }
.company-area-row > div { display: flex; align-items: center; gap: 7px; }
.company-inline-action { margin-top: 13px; border: 1px dashed #cfd7e5; border-radius: 10px; background: #fbfcfe; }
.company-inline-action summary { display: flex; align-items: center; gap: 7px; padding: 13px 14px; cursor: pointer; color: var(--blue); font-size: 12px; font-weight: 750; list-style: none; }
.company-inline-action summary::-webkit-details-marker { display: none; }
.company-inline-action form { display: grid; gap: 13px; padding: 0 14px 14px; }
.company-metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; }
.company-metrics > div { display: grid; gap: 6px; padding: 16px; border: 1px solid #e2e7ef; border-radius: 11px; background: #f8faff; }
.company-metrics small { color: var(--muted); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; }
.company-metrics strong { color: #173b7a; font-size: 25px; line-height: 1; letter-spacing: -.04em; }
.company-doc-heading { align-items: center; }
.company-doc-counts { display: flex; gap: 7px; }
.company-doc-counts span { padding: 6px 9px; border-radius: 999px; color: #536078; background: #f0f3f8; font-size: 10px; font-weight: 750; }
.company-document-search { display: flex; align-items: center; gap: 9px; width: 100%; margin-bottom: 17px; padding: 0 12px; border: 1px solid #d8dee8; border-radius: 10px; background: white; }
.company-document-search .icon { color: #818ba0; }
.company-document-search input { width: 100%; min-height: 43px; border: 0; outline: 0; background: transparent; font-size: 12px; }
.company-doc-table table { min-width: 870px; }
.company-doc-table td { vertical-align: middle; }
.company-document-list { display: grid; gap: 8px; }
.company-document-list > div { display: flex; align-items: center; justify-content: space-between; gap: 15px; padding: 13px 14px; border: 1px solid #e2e6ed; border-radius: 10px; background: #fbfcfd; }
.company-document-list strong, .company-document-list small { display: block; }
.company-document-list strong { font-size: 12px; }
.company-document-list small { margin-top: 4px; color: var(--muted); font-size: 10px; }
.company-more { margin-top: 13px; }
.company-more summary { cursor: pointer; color: #59657b; font-size: 11px; font-weight: 750; }
.company-identifier-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 8px; margin-top: 10px; }
.company-identifier-list > div { display: grid; gap: 3px; padding: 11px 12px; border: 1px solid var(--line); border-radius: 9px; background: #fafbfc; }
.company-identifier-list strong { font-size: 10px; color: var(--blue); }
.company-identifier-list span { font-size: 12px; font-weight: 750; }
.company-identifier-list small { color: var(--muted); font-size: 9px; }
.company-advanced-link { display: flex; align-items: center; justify-content: space-between; gap: 15px; padding: 12px 15px; border: 1px dashed #ccd4e2; border-radius: 10px; color: var(--muted); background: rgba(255,255,255,.55); font-size: 11px; }
.company-advanced-link a { color: var(--blue); font-weight: 750; text-decoration: none; }
@media (max-width: 760px) {
.company-hero { align-items: flex-start; flex-direction: column; }
.company-hero-actions { width: 100%; justify-content: space-between; }
.company-card { padding: 18px; }
.company-card-heading { align-items: flex-start; flex-direction: column; }
.company-data-grid, .company-metrics { grid-template-columns: 1fr; }
.company-area-row { align-items: flex-start; flex-direction: column; }
.company-area-row > div { width: 100%; justify-content: space-between; }
.company-doc-counts { width: 100%; }
.company-advanced-link { align-items: flex-start; flex-direction: column; }
}
-106
View File
@@ -1,106 +0,0 @@
.territory-page { display: grid; gap: 18px; }
.territory-breadcrumb { margin-bottom: 0; }
.territory-hero {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 24px;
padding: 4px 0 2px;
}
.territory-hero h1 { margin: 6px 0 5px; font-size: clamp(31px, 3.5vw, 44px); line-height: 1.02; letter-spacing: -.045em; }
.territory-hero p { margin: 0; color: var(--muted); font-size: 13px; }
.territory-hero-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.territory-purpose-note {
display: flex;
align-items: center;
gap: 9px;
padding: 11px 13px;
border: 1px solid #dbe4f2;
border-radius: 10px;
color: #536078;
background: #f7f9fd;
font-size: 11px;
line-height: 1.45;
}
.territory-purpose-note .icon { color: var(--blue); flex: 0 0 auto; }
.territory-tabs {
display: flex;
align-items: center;
gap: 4px;
overflow-x: auto;
padding: 0 0 1px;
border-bottom: 1px solid var(--line);
}
.territory-tabs button {
position: relative;
min-height: 43px;
padding: 9px 13px;
border: 0;
color: #566178;
background: transparent;
cursor: pointer;
white-space: nowrap;
font-size: 12px;
font-weight: 700;
}
.territory-tabs button:hover { color: var(--ink); }
.territory-tabs button.active { color: var(--blue); }
.territory-tabs button.active::after { content: ''; position: absolute; left: 10px; right: 10px; bottom: -1px; height: 2px; border-radius: 2px; background: var(--blue); }
.territory-stack { display: grid; gap: 15px; }
.territory-card { padding: 22px; }
.territory-card-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; margin-bottom: 16px; }
.territory-card-heading h2 { margin: 4px 0; }
.territory-card-heading p { max-width: 680px; margin: 0; color: var(--muted); font-size: 12px; line-height: 1.5; }
.territory-card-actions { display: flex; justify-content: flex-end; margin-top: 14px; }
.territory-data-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1px; overflow: hidden; border: 1px solid var(--line); border-radius: 11px; background: var(--line); }
.territory-data-grid > div { min-height: 74px; padding: 15px 16px; background: #fff; }
.territory-data-grid > div.wide { grid-column: 1 / -1; }
.territory-data-grid small, .territory-data-grid strong { display: block; }
.territory-data-grid small { margin-bottom: 7px; color: var(--muted); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; }
.territory-data-grid strong { font-size: 13px; font-weight: 750; line-height: 1.45; }
.territory-edit-form { display: grid; gap: 15px; }
.territory-child-list, .territory-company-list { display: grid; gap: 8px; }
.territory-child-row, .territory-company-row {
display: flex;
align-items: center;
gap: 12px;
min-height: 60px;
padding: 11px 13px;
border: 1px solid #e2e7ef;
border-radius: 10px;
color: inherit;
background: #fbfcfd;
text-decoration: none;
}
.territory-child-row:hover, .territory-company-row:hover { border-color: #bfd0ee; background: #f7faff; }
.territory-child-row > span:nth-child(2), .territory-company-row > span:first-child { flex: 1; min-width: 0; }
.territory-child-row strong, .territory-child-row small, .territory-company-row strong, .territory-company-row small { display: block; }
.territory-child-row strong, .territory-company-row strong { font-size: 12px; }
.territory-child-row small, .territory-company-row small { margin-top: 4px; color: var(--muted); font-size: 10px; }
.territory-child-row > .icon:last-child, .territory-company-row > .icon:last-child { color: #8b96aa; }
.territory-metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; }
.territory-metrics > div { display: grid; gap: 6px; padding: 16px; border: 1px solid #e2e7ef; border-radius: 11px; background: #f8faff; }
.territory-metrics small { color: var(--muted); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; }
.territory-metrics strong { color: #173b7a; font-size: 25px; line-height: 1; letter-spacing: -.04em; }
.territory-advanced-link { display: flex; align-items: center; justify-content: space-between; gap: 15px; padding: 12px 15px; border: 1px dashed #ccd4e2; border-radius: 10px; color: var(--muted); background: rgba(255,255,255,.55); font-size: 11px; }
.territory-advanced-link a { color: var(--blue); font-weight: 750; text-decoration: none; }
@media (max-width: 760px) {
.territory-hero { align-items: flex-start; flex-direction: column; }
.territory-hero-actions { width: 100%; }
.territory-card { padding: 18px; }
.territory-card-heading { align-items: flex-start; flex-direction: column; }
.territory-data-grid, .territory-metrics { grid-template-columns: 1fr; }
.territory-data-grid > div.wide { grid-column: auto; }
.territory-child-row { align-items: flex-start; flex-wrap: wrap; }
.territory-child-row .status-badge { margin-left: 44px; }
.territory-advanced-link { align-items: flex-start; flex-direction: column; }
}