Compare commits

...
Author SHA1 Message Date
admin 2455a89c3e test(android): fijar selección estructural por código 2026-09-11 12:43:11 -03:00
DH Android Finalizer 2a99183d31 fix(android): usar código estructural para Instalación y Subinstalación 2026-09-11 15:42:40 +00:00
admin 675d01bea7 chore: corregir clasificación estructural Android 2026-09-11 12:42:32 -03:00
admin 26d79f1e4d docs(android): fijar checklist de validación final 0.19.0 2026-09-11 12:37:16 -03:00
DH Android Finalizer 496bb785c1 fix(android): preservar ViewModel estable en release final 2026-09-11 15:36:42 +00:00
admin f513817388 chore: restaurar MainViewModel y aplicar cambios mínimos 0.19.0 2026-09-11 12:36:32 -03:00
admin 92a21d8f15 fix(android): limpiar firma y terminología final de Actas 2026-09-11 12:35:02 -03:00
admin 753fa9915f test(android): actualizar metadata de release 0.19.0 2026-09-11 12:33:09 -03:00
admin ec0ef732f7 test: actualizar contrato Android a 0.19.0 2026-09-11 12:27:42 -03:00
DH Android Finalizer 87adf71114 android-finalizer: finish Spanish visible copy 2026-09-11 15:24:59 +00:00
admin 0a856a1857 chore: completar limpieza de castellano Android 2026-09-11 12:24:51 -03:00
admin 247c652d40 fix(android): eliminar anglicismo visible en firma 2026-09-11 12:24:33 -03:00
admin b5ee17ce95 docs(android): documentar release final 0.19.0 2026-09-11 12:24:21 -03:00
admin cca2d38b98 test(android): fijar flujo final de inspección y castellano 2026-09-11 12:24:06 -03:00
DH Android Finalizer ebce2bb957 android-finalizer: cleanup final and remove temporary workflow 2026-09-11 15:23:15 +00:00
admin 963485618b chore: cerrar finalizador temporal Android 2026-09-11 12:23:06 -03:00
DH Android Finalizer 8f051158c1 android-finalizer: apply final field flow and Spanish UX 2026-09-11 15:21:04 +00:00
admin 2d8425dec5 chore: corregir ejecutor final Android 2026-09-11 12:20:55 -03:00
admin cfae2bb9ac chore: aplicar cierre final Android de campo 2026-09-11 12:19:50 -03:00
admin 5d8949d7d2 fix(android): mostrar estados de inspección en castellano 2026-09-11 12:18:25 -03:00
admin 304adcd9b0 feat(android): centralizar estados visibles en castellano 2026-09-11 12:17:52 -03:00
admin 20dd110928 release(android): preparar 0.19.0 final de campo 2026-09-11 12:17:35 -03:00
admin 80ffb35065 Merge pull request #51 from enlineawork/fix/yacimiento-companies-admin-advanced
fix(web): empresas en yacimientos y acceso avanzado de administrador
2026-09-11 11:01:30 -03:00
admin 6f79abbcff fix(web): mostrar empresas heredadas en yacimientos y proteger avanzado 2026-09-11 10:56:16 -03:00
admin d18375b85b fix(web): reservar ficha avanzada al administrador 2026-09-11 10:55:26 -03:00
admin 85a84e0f63 fix(web): centralizar acceso a administración avanzada 2026-09-11 10:55:08 -03:00
admin 03f42209b9 Merge pull request #50 from enlineawork/feature/territorial-simple-profile
feat(web): simplificar fichas territoriales
2026-09-11 10:39:50 -03:00
17 changed files with 324 additions and 92 deletions
+15
View File
@@ -0,0 +1,15 @@
# Checklist final Android 0.19.0
La candidata sólo se promueve si el mismo SHA cumple:
- iniciar Inspección;
- crear/abrir Acta;
- seleccionar o crear Instalación/Subinstalación;
- exigir GPS + fotografía para Inventario nacido en campo antes de Hallazgos;
- registrar Hallazgos sobre el elemento seleccionado;
- cerrar contenido del Acta y mostrar **Pendiente de firma**;
- completar firma del Inspector y manifestación/firma o negativa de la empresa;
- mostrar el Acta como **Firmada y cerrada**;
- permitir cerrar la Inspección sólo con todas las Actas activas firmadas y cerradas;
- no exponer estados internos en inglés en la interfaz de campo;
- Android CI / RC, DH V2 CI y auditoría de dependencias en verde.
+57 -26
View File
@@ -1,40 +1,71 @@
# DH Inspección Android · release Campo Moderno 0.18.0 # DH Inspección Android · release final de campo 0.19.0
## Candidata vigente ## Candidata vigente
- Fase funcional: **Campo Moderno / UX dinámica de Inventario y Actas**. - Fase funcional: **Flujo final de campo · Inspección → Acta → Hallazgos → Firma**.
- `versionName`: **0.18.0**. - `versionName`: **0.19.0**.
- `versionCode`: **27**. - `versionCode`: **28**.
- Application ID release: `com.korexlabs.dhinspeccion`. - Application ID release: `com.korexlabs.dhinspeccion`.
- Application ID debug/QA: `com.korexlabs.dhinspeccion.debug`. - Application ID debug/QA: `com.korexlabs.dhinspeccion.debug`.
- API: `https://dhv2.korexlabs.com/api/v3/`. - API: `https://dhv2.korexlabs.com/api/v3/`.
La variante debug es independiente de la app productiva y puede instalarse para QA/presentación sin sobrescribir una instalación release histórica. La variante debug es independiente de la app productiva y puede instalarse para QA/presentación sin sobrescribir una instalación release histórica.
## Novedades de campo 0.18.0 ## Procedimiento operativo validado
La APK adopta una capa visual Material 3 propia de DH: superficies más limpias, jerarquía visual más clara, botones y estados más legibles, tarjetas con menor ruido y una navegación consistente entre Inspección, Actas e Inventario. La APK 0.19.0 fija como recorrido principal de campo:
La carga de una Subinstalación mantiene la integridad del modelo físico pero reduce pasos: 1. **Iniciar Inspección**. Al iniciarla se habilitan Actas, Hallazgos e Inventario de campo.
2. **Crear o abrir un Acta**. Puede haber varias Actas dentro de una misma Inspección, pero sólo una puede permanecer en elaboración al mismo tiempo.
3. **Agregar Hallazgos** sobre una Instalación o Subinstalación seleccionada.
4. Si el elemento ya existe, se lo selecciona desde el Inventario del Área/Yacimiento de la Inspección.
5. Si no existe, se da de alta desde campo sin abandonar el Acta.
6. Un elemento nuevo debe completar **GPS + fotografía** antes de poder recibir Hallazgos.
7. Al terminar el contenido del Acta se usa **Cerrar Acta y dejar pendiente de firma**. Desde ese momento el contenido queda inmutable y el estado visible es **Pendiente de firma**.
8. La firma del Inspector y la manifestación/firma o negativa de la empresa completan el Acta, que pasa a **Firmada y cerrada**.
9. La Inspección sólo puede cerrarse cuando todas sus Actas no canceladas están firmadas y cerradas.
1. el buscador de **Instalaciones padre** ahora consulta mientras el inspector escribe; La nomenclatura técnica interna de API (`DRAFT`, `LOCKED`, `SEALED`, etc.) no se muestra al inspector: la interfaz usa textos operativos en castellano.
2. cada coincidencia aparece como tarjeta seleccionable y tocarla avanza directamente a la identificación de la Subinstalación;
3. la clasificación técnica continúa filtrada por compatibilidad con la Instalación elegida;
4. **Guardar y tomar foto** captura GPS y abre la cámara automáticamente.
Se conserva la regla de que un elemento nacido en campo no puede recibir Hallazgos hasta contar con GPS y fotografía. ## Alta de Instalaciones y Subinstalaciones
## Actas en Android El alta móvil queda alineada con el Dashboard y con las validaciones del servidor.
El espacio de Actas fue rediseñado para mostrar con claridad: ### Instalación
- contexto de la Inspección y Yacimiento; - contexto territorial: Yacimiento/Área de la Inspección;
- estado de cada Acta y cantidad de Hallazgos; - clasificación técnica compatible;
- urgencia mediante selección explícita; - **Nombre técnico** obligatorio;
- responsable de empresa, bloqueo, firmas, manifestación y sellado; - Nombre habitual opcional;
- condición necesaria para cerrar la Inspección. - Descripción opcional;
- atributos técnicos definidos por el tipo; sólo los marcados como obligatorios bloquean el guardado;
- código DH generado por el sistema cuando corresponde;
- GPS de la tablet;
- fotografía obligatoria antes de crear Hallazgos.
El listado que consume Android usa un read-model móvil liviano (`/inspection-visits/:visitId/acts/mobile`). Esta lectura no depende del módulo documental de oficina ni de `inspection_reports`, de modo que entrar a una Inspección en campo no queda acoplado a la proyección de Informes/GEDO. ### Subinstalación
Además de lo anterior requiere elegir primero la **Instalación padre**. El buscador consulta mientras se escribe y cada resultado es seleccionable tocando la tarjeta.
La clasificación y los atributos se obtienen dinámicamente desde el mismo catálogo administrado por el Dashboard; la APK no mantiene listas técnicas paralelas.
## Actas y firma
La terminología visible se simplifica:
- `DRAFT`**En elaboración**;
- `LOCKED`**Pendiente de firma**;
- `SEALED`**Firmada y cerrada**.
Cerrar el contenido no cierra automáticamente la Inspección y tampoco obliga a terminar la firma en ese instante: puede continuarse con otra Acta. Una vez cerrado el contenido, el Acta es inmutable.
Después de la firma/cierre definitivo se mantiene el circuito documental existente: PDF del Acta e informe INF editable (DOCX) para oficina.
## Castellano e identidad visual
Se revisaron las pantallas activas de campo para evitar exponer estados y opciones internas en inglés. Entre otros cambios: Pasaporte/Otro en documentos, estados de Inspección/Acta/Hallazgo en castellano, Correo electrónico, Huella de integridad y terminología de cierre orientada al usuario.
La pantalla de ingreso y el desbloqueo biométrico utilizan la identidad visual de Mendoza ya incorporada al proyecto.
## Barrera obligatoria ## Barrera obligatoria
@@ -42,16 +73,16 @@ Todo cambio Android o de API que pueda afectar al cliente móvil debe pasar `And
1. validación de identidad, HTTPS y políticas básicas del manifest; 1. validación de identidad, HTTPS y políticas básicas del manifest;
2. Android lint; 2. Android lint;
3. unit tests Android reales, con verificación de que exista al menos una prueba ejecutada; 3. pruebas unitarias Android reales, con verificación de que exista al menos una prueba ejecutada;
4. `assembleDebug`; 4. `assembleDebug`;
5. `assembleRelease` para comprobar que la variante productiva compile; 5. `assembleRelease` para comprobar que la variante productiva compile;
6. empaquetado del APK debug con SHA-256 y metadata de commit/versionado. 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 promover el cambio. Además, `DH V2 CI` debe mantener verdes API, WEB y contrato Docker/migraciones antes de promover cambios que afecten al servidor.
## Firma release ## Firma release
La clave histórica de firma **no se versiona ni se reemplaza**. La CI compila la variante release para detectar roturas, pero la APK productiva final debe firmarse con la clave histórica antes de instalarse como actualización de `com.korexlabs.dhinspeccion`. La clave histórica de firma **no se versiona ni se reemplaza**. La CI compila la variante release para detectar roturas, pero una APK productiva que deba actualizar `com.korexlabs.dhinspeccion` tiene que firmarse con la clave histórica.
No se debe crear una clave nueva para resolver una falta de acceso: eso rompería la continuidad de actualización de tablets que ya tengan una versión firmada con la clave anterior. No se debe crear una clave nueva para resolver una falta de acceso: eso rompería la continuidad de actualización de tablets que ya tengan una versión firmada con la clave anterior.
@@ -60,7 +91,7 @@ No se debe crear una clave nueva para resolver una falta de acceso: eso romperí
Antes de distribuir una APK productiva: Antes de distribuir una APK productiva:
- todas las barreras de CI del SHA exacto deben estar verdes; - todas las barreras de CI del SHA exacto deben estar verdes;
- comprobar certificado/huella de firma contra la versión histórica; - comprobar certificado/huella de firma contra la versión histórica si se distribuirá como actualización productiva;
- realizar actualización sobre al menos una tablet con la versión productiva anterior; - realizar actualización sobre al menos una tablet con la versión productiva anterior cuando corresponda;
- ejecutar smoke funcional contra el entorno objetivo: login, abrir Inspección, abrir Actas sin error, crear Acta, búsqueda dinámica de Instalación, selección de padre, alta de Subinstalación, GPS/foto, Hallazgo y cierre; - ejecutar smoke funcional: ingreso, iniciar Inspección, crear Acta, seleccionar/crear Instalación o Subinstalación, GPS/foto, Hallazgo, cerrar Acta → Pendiente de firma, firma/manifestación, cierre de Acta y cierre de Inspección;
- registrar el SHA Git y SHA-256 de la APK distribuida. - registrar el SHA Git y SHA-256 de la APK distribuida.
+2 -2
View File
@@ -12,8 +12,8 @@ android {
applicationId = "com.korexlabs.dhinspeccion" applicationId = "com.korexlabs.dhinspeccion"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 27 versionCode = 28
versionName = "0.18.0" versionName = "0.19.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true vectorDrawables.useSupportLibrary = true
@@ -169,7 +169,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
return return
} }
if (acts.any { it.status == "DRAFT" }) { if (acts.any { it.status == "DRAFT" }) {
error = "Ya existe un Acta en borrador. Bloqueala o cancelala antes de crear la siguiente." error = "Ya existe un Acta en elaboración. Cerrala o cancelala antes de crear la siguiente."
return return
} }
launchBusy { launchBusy {
@@ -241,6 +241,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
latitude: Double, latitude: Double,
longitude: Double, longitude: Double,
accuracyM: Double?, accuracyM: Double?,
description: String? = null,
) { ) {
val visitId = visit?.id ?: return val visitId = visit?.id ?: return
if (visit?.status != "IN_PROGRESS") { if (visit?.status != "IN_PROGRESS") {
@@ -258,6 +259,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
familyId = familyId, familyId = familyId,
name = name.trim(), name = name.trim(),
commonName = commonName?.trim()?.takeIf { it.isNotBlank() }, commonName = commonName?.trim()?.takeIf { it.isNotBlank() },
description = description?.trim()?.takeIf { it.isNotBlank() },
attributes = attributes, attributes = attributes,
deviceLatitude = latitude, deviceLatitude = latitude,
deviceLongitude = longitude, deviceLongitude = longitude,
@@ -340,7 +342,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
val assetId = selectedFieldAsset?.asset?.id ?: return val assetId = selectedFieldAsset?.asset?.id ?: return
val draft = selectedDraftAct() val draft = selectedDraftAct()
if (draft == null) { if (draft == null) {
error = "Creá o seleccioná el Acta en borrador antes de registrar Hallazgos." error = "Creá o seleccioná el Acta en elaboración antes de registrar Hallazgos."
return return
} }
launchBusy { launchBusy {
@@ -362,7 +364,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
val assetId = selectedFieldAsset?.asset?.id ?: return val assetId = selectedFieldAsset?.asset?.id ?: return
val actId = selectedDraftAct()?.id val actId = selectedDraftAct()?.id
if (actId == null) { if (actId == null) {
error = "No hay un Acta en borrador seleccionada." error = "No hay un Acta en elaboración seleccionada."
return return
} }
if (description.isBlank()) { if (description.isBlank()) {
@@ -471,7 +473,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
absenceReason = reason.trim(), absenceReason = reason.trim(),
), ),
) )
notice = "Ausencia del responsable registrada. La manifestación de empresa quedará pendiente y deberá resolverse antes de sellar el Acta." notice = "Ausencia del responsable registrada. La manifestación de empresa quedará pendiente y deberá resolverse antes de firmar y cerrar el Acta."
} }
} }
@@ -480,12 +482,12 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
launchBusy { launchBusy {
actClosure = actsRepository.lock(actId) actClosure = actsRepository.lock(actId)
refreshSelectedActInternal(actId) refreshSelectedActInternal(actId)
notice = "Acta bloqueada. Su contenido quedó inmutable; ahora deben resolverse las firmas y manifestaciones." notice = "Acta cerrada y pendiente de firma. Su contenido quedó inmutable; las firmas y manifestaciones pueden completarse a continuación."
} }
} }
fun reopenSelectedAct() { fun reopenSelectedAct() {
error = "Un Acta bloqueada es inmutable y no puede volver a borrador." error = "Un Acta pendiente de firma es inmutable y no puede volver a edición."
} }
fun signSelectedActAsInspector( fun signSelectedActAsInspector(
@@ -546,7 +548,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
refreshSelectedActInternal(actId) refreshSelectedActInternal(actId)
loadActsInternal(currentVisit.id, selectDraft = false) loadActsInternal(currentVisit.id, selectDraft = false)
clearFindingState() clearFindingState()
notice = "${selectedAct?.code ?: "Acta"} SELLADA e inmutable. Podés crear otra Acta o continuar hacia el cierre de la Inspección." notice = "${selectedAct?.code ?: "Acta"} firmada y cerrada. Podés crear otra Acta o continuar hacia el cierre de la Inspección."
} }
} }
@@ -555,7 +557,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
launchBusy { launchBusy {
visit = actsRepository.closeVisit(visitId) visit = actsRepository.closeVisit(visitId)
loadVisitsInternal() loadVisitsInternal()
notice = "Inspección cerrada. Todas sus Actas quedaron SELLADAS y disponibles para el circuito de oficina." notice = "Inspección cerrada. Todas sus Actas quedaron firmadas y disponibles para el circuito de oficina."
} }
} }
@@ -185,7 +185,7 @@ fun FieldFindingScreen(model: MainViewModel) {
Text(asset.name, fontWeight = FontWeight.Bold) Text(asset.name, fontWeight = FontWeight.Bold)
Text("${asset.code} · Acta ${options.act.code}", style = MaterialTheme.typography.bodySmall) Text("${asset.code} · Acta ${options.act.code}", style = MaterialTheme.typography.bodySmall)
Text( Text(
"GPS + foto del Inventario: ${if (options.capture.readyForFinding) "OK" else "pendiente"}", "GPS + foto del Inventario: ${if (options.capture.readyForFinding) "completo" else "pendiente"}",
color = if (options.capture.readyForFinding) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error, color = if (options.capture.readyForFinding) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
) )
@@ -210,7 +210,7 @@ fun FieldFindingScreen(model: MainViewModel) {
Card(Modifier.fillMaxWidth()) { Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { Column(Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text("${finding.code} · ${finding.title}", fontWeight = FontWeight.SemiBold) Text("${finding.code} · ${finding.title}", fontWeight = FontWeight.SemiBold)
Text("Gravedad: ${finding.severity ?: "s/d"} · ${finding.status}", style = MaterialTheme.typography.bodySmall) Text("Gravedad: ${finding.severity ?: "s/d"} · ${findingStatusLabelEs(finding.status)}", style = MaterialTheme.typography.bodySmall)
Text( Text(
"Evidencias: ${evidence.size} · Fotos: ${evidence.count { it.kind == "PHOTO" }}", "Evidencias: ${evidence.size} · Fotos: ${evidence.count { it.kind == "PHOTO" }}",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
@@ -3,11 +3,13 @@ package com.korexlabs.dhinspeccion.ui
import android.content.Context import android.content.Context
import androidx.biometric.BiometricManager import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt import androidx.biometric.BiometricPrompt
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.Visibility
@@ -29,7 +31,9 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.input.VisualTransformation
@@ -37,6 +41,7 @@ import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
import com.korexlabs.dhinspeccion.MainViewModel import com.korexlabs.dhinspeccion.MainViewModel
import com.korexlabs.dhinspeccion.R
private const val BIOMETRIC_PREFS = "dh_v2_biometric" private const val BIOMETRIC_PREFS = "dh_v2_biometric"
private const val BIOMETRIC_ENABLED = "enabled" private const val BIOMETRIC_ENABLED = "enabled"
@@ -105,7 +110,13 @@ private fun EnhancedLoginScreen(model: MainViewModel, onPasswordLogin: () -> Uni
var passwordVisible by rememberSaveable { mutableStateOf(false) } var passwordVisible by rememberSaveable { mutableStateOf(false) }
Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(14.dp)) { Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(14.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Image(
painter = painterResource(R.drawable.ic_mendoza_launcher_exact),
contentDescription = "Gobierno de Mendoza",
modifier = Modifier.fillMaxWidth().height(170.dp),
contentScale = ContentScale.Fit,
)
Text("DH Inspección", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) Text("DH Inspección", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
Text("Aplicación de campo · Dirección de Hidrocarburos") Text("Aplicación de campo · Dirección de Hidrocarburos")
model.error?.let { Text(it, color = MaterialTheme.colorScheme.error) } model.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
@@ -113,7 +124,7 @@ private fun EnhancedLoginScreen(model: MainViewModel, onPasswordLogin: () -> Uni
OutlinedTextField( OutlinedTextField(
value = identifier, value = identifier,
onValueChange = { identifier = it }, onValueChange = { identifier = it },
label = { Text("Usuario o email") }, label = { Text("Usuario o correo electrónico") },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true, singleLine = true,
) )
@@ -198,6 +209,12 @@ private fun BiometricUnlockScreen(
verticalArrangement = Arrangement.spacedBy(14.dp), verticalArrangement = Arrangement.spacedBy(14.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Image(
painter = painterResource(R.drawable.ic_mendoza_launcher_exact),
contentDescription = "Gobierno de Mendoza",
modifier = Modifier.fillMaxWidth().height(140.dp),
contentScale = ContentScale.Fit,
)
Text("DH Inspección", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) Text("DH Inspección", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
Text("Ingresá con tu huella") Text("Ingresá con tu huella")
error?.let { Text(it, color = MaterialTheme.colorScheme.error) } error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
@@ -215,7 +215,7 @@ private fun MobileVisitCard(visit: VisitSummary, onOpen: () -> Unit) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) { Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(visit.code, fontWeight = FontWeight.Bold) Text(visit.code, fontWeight = FontWeight.Bold)
Text(visit.status) Text(visitStatusLabelEs(visit.status))
} }
Text(visit.operatorCompany?.name ?: "Operadora sin definir") Text(visit.operatorCompany?.name ?: "Operadora sin definir")
Text(visit.operationalArea?.name ?: "Área sin definir", style = MaterialTheme.typography.bodySmall) Text(visit.operationalArea?.name ?: "Área sin definir", style = MaterialTheme.typography.bodySmall)
@@ -245,7 +245,7 @@ fun ModernMobileActsScreen(
) { ) {
Text( Text(
if (newActUrgency == "URGENT") { if (newActUrgency == "URGENT") {
"El plazo urgente se computará desde el Acta conforme a la política institucional vigente al bloquearla." "El plazo urgente se computará desde el Acta conforme a la política institucional vigente al cerrar su contenido."
} else { } else {
"El plazo no urgente comienza con la oficialización GEDO, no al crear el Acta." "El plazo no urgente comienza con la oficialización GEDO, no al crear el Acta."
}, },
@@ -319,12 +319,12 @@ fun ModernMobileActsScreen(
OutlinedTextField(fullName, { fullName = it }, label = { Text("Nombre y apellido *") }, modifier = Modifier.fillMaxWidth(), singleLine = true) OutlinedTextField(fullName, { fullName = it }, label = { Text("Nombre y apellido *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
listOf("DNI", "CUIL", "PASSPORT", "OTHER").forEach { kind -> listOf("DNI", "CUIL", "PASSPORT", "OTHER").forEach { kind ->
FilterChip(selected = documentType == kind, onClick = { documentType = kind }, label = { Text(kind) }) FilterChip(selected = documentType == kind, onClick = { documentType = kind }, label = { Text(documentTypeLabelEs(kind)) })
} }
} }
OutlinedTextField(documentNumber, { documentNumber = it }, label = { Text("Documento *") }, modifier = Modifier.fillMaxWidth(), singleLine = true) 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(position, { position = it }, label = { Text("Cargo *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
OutlinedTextField(email, { email = it }, label = { Text("Email") }, 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) OutlinedTextField(phone, { phone = it }, label = { Text("Teléfono") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
Button( Button(
onClick = { model.setCompanyResponsiblePresent(fullName, documentType, documentNumber, position, email, phone) }, onClick = { model.setCompanyResponsiblePresent(fullName, documentType, documentNumber, position, email, phone) },
@@ -353,17 +353,17 @@ fun ModernMobileActsScreen(
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Filled.Lock, null, tint = MaterialTheme.colorScheme.error) Icon(Icons.Filled.Lock, null, tint = MaterialTheme.colorScheme.error)
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
Text("Finalizar contenido", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) Text("Cerrar Acta", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
} }
Text( Text(
"Al bloquear el Acta, su contenido y sus Hallazgos quedan inmutables.", "Al cerrar el contenido, el Acta queda inmutable y pasa a Pendiente de firma. Podés firmarla ahora o continuar con otra Acta.",
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
Button( Button(
onClick = { model.prepareSelectedAct() }, onClick = { model.prepareSelectedAct() },
enabled = !model.busy && closure?.responsible != null, enabled = !model.busy && closure?.responsible != null,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { Text("Finalizar y BLOQUEAR Acta") } ) { Text("Cerrar Acta y dejar pendiente de firma") }
} }
} }
} }
@@ -379,10 +379,10 @@ fun ModernMobileActsScreen(
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Filled.Lock, null, tint = MaterialTheme.colorScheme.secondary) Icon(Icons.Filled.Lock, null, tint = MaterialTheme.colorScheme.secondary)
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
Text("Acta BLOQUEADA", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) Text("Acta pendiente de firma", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
} }
Text("El contenido ya es inmutable. Restan firmas y manifestaciones para poder sellarla.") Text("El contenido ya está cerrado e inmutable. Completá la firma del inspector y la manifestación de la empresa cuando corresponda.")
closure?.closure?.preparedSha256?.let { Text("Hash · $it", style = MaterialTheme.typography.bodySmall) } closure?.closure?.preparedSha256?.let { Text("Huella de integridad · $it", style = MaterialTheme.typography.bodySmall) }
HorizontalDivider() HorizontalDivider()
Text("Firma del inspector", fontWeight = FontWeight.Bold) Text("Firma del inspector", fontWeight = FontWeight.Bold)
@@ -403,7 +403,7 @@ fun ModernMobileActsScreen(
val detail = when (companyOutcome.status) { val detail = when (companyOutcome.status) {
"SIGNED" -> if (companyOutcome.companyManifestation == "DISSENT") "Firma en disidencia" else "Firma en conformidad" "SIGNED" -> if (companyOutcome.companyManifestation == "DISSENT") "Firma en disidencia" else "Firma en conformidad"
"REFUSED" -> "Negativa a firmar" "REFUSED" -> "Negativa a firmar"
else -> companyOutcome.status else -> humanBackendLabelEs(companyOutcome.status)
} }
SuccessLine(detail) SuccessLine(detail)
companyOutcome.reason?.let { Text(it, style = MaterialTheme.typography.bodySmall) } companyOutcome.reason?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
@@ -413,7 +413,7 @@ fun ModernMobileActsScreen(
Row(Modifier.padding(12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(Modifier.padding(12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Filled.ErrorOutline, null, tint = MaterialTheme.colorScheme.error) Icon(Icons.Filled.ErrorOutline, null, tint = MaterialTheme.colorScheme.error)
Text( Text(
"La ausencia no resuelve la manifestación. Debe registrarse firma o negativa antes de sellar.", "La ausencia no resuelve la manifestación. Debe registrarse firma o negativa antes de firmar y cerrar el Acta.",
Modifier.weight(1f), Modifier.weight(1f),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
) )
@@ -462,7 +462,7 @@ fun ModernMobileActsScreen(
) { ) {
Icon(Icons.Filled.CheckCircle, null) Icon(Icons.Filled.CheckCircle, null)
Spacer(Modifier.width(6.dp)) Spacer(Modifier.width(6.dp))
Text("SELLAR Acta definitivamente") Text("Firmar y cerrar Acta")
} }
} else { } else {
Text( Text(
@@ -480,8 +480,8 @@ fun ModernMobileActsScreen(
Row(Modifier.padding(18.dp), horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.Top) { Row(Modifier.padding(18.dp), horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.Top) {
Icon(Icons.Filled.CheckCircle, null, tint = MaterialTheme.colorScheme.primary) Icon(Icons.Filled.CheckCircle, null, tint = MaterialTheme.colorScheme.primary)
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(5.dp)) { Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(5.dp)) {
Text("Acta SELLADA e inmutable", fontWeight = FontWeight.Bold) Text("Acta firmada y cerrada", fontWeight = FontWeight.Bold)
Text("Desde este sellado se genera el PDF del Acta y el INF Word editable para el Inspector.") Text("Desde este cierre se genera el PDF del Acta y el informe INF editable (DOCX) para el Inspector.")
closure?.closure?.finalSha256?.let { Text("SHA-256 · $it", style = MaterialTheme.typography.bodySmall) } closure?.closure?.finalSha256?.let { Text("SHA-256 · $it", style = MaterialTheme.typography.bodySmall) }
} }
} }
@@ -503,10 +503,10 @@ fun ModernMobileActsScreen(
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(9.dp)) { Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(9.dp)) {
Text("Finalizar Inspección", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) Text("Finalizar Inspección", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
if (pendingActs.isEmpty()) { if (pendingActs.isEmpty()) {
SuccessLine("Todas las Actas están SELLADAS") SuccessLine("Todas las Actas están firmadas y cerradas")
} else { } else {
Text( Text(
"Falta SELLAR: ${pendingActs.joinToString { it.code }}", "Falta firmar y cerrar: ${pendingActs.joinToString { it.code }}",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error, color = MaterialTheme.colorScheme.error,
) )
@@ -531,13 +531,7 @@ private fun SuccessLine(text: String) {
} }
} }
private fun modernActStatusLabel(status: String): String = when (status) { private fun modernActStatusLabel(status: String): String = actStatusLabelEs(status)
"DRAFT" -> "Borrador"
"LOCKED" -> "Bloqueada"
"SEALED" -> "Sellada"
"CANCELLED" -> "Cancelada"
else -> status
}
private fun hasModernActLocation(context: Context): Boolean = private fun hasModernActLocation(context: Context): Boolean =
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED || ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
@@ -274,7 +274,7 @@ private fun ModernChecklistCard(visit: VisitDetail) {
) )
} }
if (checklist.stale) { if (checklist.stale) {
Text("El checklist requiere actualización.", color = MaterialTheme.colorScheme.error) Text("La lista de control requiere actualización.", color = MaterialTheme.colorScheme.error)
} }
} }
} }
@@ -296,6 +296,7 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
var parentLabel by rememberSaveable(visit.id) { mutableStateOf("") } var parentLabel by rememberSaveable(visit.id) { mutableStateOf("") }
var name by rememberSaveable(visit.id) { mutableStateOf("") } var name by rememberSaveable(visit.id) { mutableStateOf("") }
var commonName by rememberSaveable(visit.id) { mutableStateOf("") } var commonName by rememberSaveable(visit.id) { mutableStateOf("") }
var description by rememberSaveable(visit.id) { mutableStateOf("") }
var selectedTypeId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) } var selectedTypeId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
var selectedFamilyId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) } var selectedFamilyId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
var familySearch by rememberSaveable(visit.id) { mutableStateOf("") } var familySearch by rememberSaveable(visit.id) { mutableStateOf("") }
@@ -311,6 +312,7 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
fun resetForm() { fun resetForm() {
name = "" name = ""
commonName = "" commonName = ""
description = ""
selectedTypeId = null selectedTypeId = null
selectedFamilyId = null selectedFamilyId = null
familySearch = "" familySearch = ""
@@ -444,6 +446,7 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
familyId = selectedFamilyId, familyId = selectedFamilyId,
name = name, name = name,
commonName = commonName, commonName = commonName,
description = description,
attributes = buildModernAttributes(type, attributeValues), attributes = buildModernAttributes(type, attributeValues),
latitude = geo.latitude, latitude = geo.latitude,
longitude = geo.longitude, longitude = geo.longitude,
@@ -800,18 +803,26 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
OutlinedTextField( OutlinedTextField(
value = name, value = name,
onValueChange = { name = it }, onValueChange = { name = it },
label = { Text("Nombre o código visible *") }, label = { Text("Nombre técnico *") },
supportingText = { Text("Usá lo que figura en placa o identifica el elemento en campo.") }, supportingText = { Text("Usá la denominación técnica del elemento. El código DH se genera automáticamente.") },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true, singleLine = true,
) )
OutlinedTextField( OutlinedTextField(
value = commonName, value = commonName,
onValueChange = { commonName = it }, onValueChange = { commonName = it },
label = { Text("Nombre habitual") }, label = { Text("Nombre habitual (opcional)") },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true, singleLine = true,
) )
OutlinedTextField(
value = description,
onValueChange = { description = it },
label = { Text("Descripción (opcional)") },
supportingText = { Text("Dato equivalente al alta desde el panel web.") },
modifier = Modifier.fillMaxWidth(),
minLines = 2,
)
selectedType?.attributes?.forEach { definition -> selectedType?.attributes?.forEach { definition ->
OutlinedTextField( OutlinedTextField(
@@ -1066,20 +1077,14 @@ private fun ModernStepHeader(step: Int, title: String) {
private fun modernItemTypeCode(item: FieldInventoryItem): String { private fun modernItemTypeCode(item: FieldInventoryItem): String {
val type = item.type ?: return "" val type = item.type ?: return ""
return modernNormalize(type.typeName ?: type.name) return modernNormalize(type.code.ifBlank { type.typeName ?: type.name })
} }
private fun modernNormalize(value: String): String = value.trim().lowercase() private fun modernNormalize(value: String): String = value.trim().lowercase()
.replace('ó', 'o').replace('í', 'i').replace('á', 'a').replace('é', 'e').replace('ú', 'u') .replace('ó', 'o').replace('í', 'i').replace('á', 'a').replace('é', 'e').replace('ú', 'u')
.replace("_", "").replace("-", "").replace(" ", "") .replace("_", "").replace("-", "").replace(" ", "")
private fun modernStatusLabel(status: String): String = when (status) { private fun modernStatusLabel(status: String): String = visitStatusLabelEs(status)
"PLANNED" -> "Planificada"
"IN_PROGRESS" -> "En curso"
"CLOSED" -> "Cerrada"
"CANCELLED" -> "Cancelada"
else -> status
}
private fun buildModernAttributes(type: FieldType, values: Map<String, String>): Map<String, Any?> = private fun buildModernAttributes(type: FieldType, values: Map<String, String>): Map<String, Any?> =
type.attributes.mapNotNull { definition -> type.attributes.mapNotNull { definition ->
@@ -49,7 +49,7 @@ fun SignaturePad(
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(label, fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold) Text(label, fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold)
Text( Text(
"Firmá dentro del recuadro. La imagen se guarda como PNG y se incorpora al hash del Acta.", "Firmá dentro del recuadro. La imagen se guarda como PNG y se incorpora a la huella de integridad del Acta.",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
) )
Canvas( Canvas(
@@ -0,0 +1,50 @@
package com.korexlabs.dhinspeccion.ui
import java.util.Locale
internal fun visitStatusLabelEs(status: String?): String = when (status?.uppercase(Locale.ROOT)) {
"PLANNED" -> "Planificada"
"IN_PROGRESS" -> "En curso"
"CLOSED" -> "Cerrada"
"CANCELLED" -> "Cancelada"
null, "" -> "Sin estado"
else -> humanBackendLabelEs(status)
}
internal fun actStatusLabelEs(status: String?): String = when (status?.uppercase(Locale.ROOT)) {
"DRAFT" -> "En elaboración"
"LOCKED" -> "Pendiente de firma"
"SEALED" -> "Firmada y cerrada"
"CLOSED" -> "Cerrada"
"RECTIFIED" -> "Rectificada"
"CANCELLED" -> "Cancelada"
null, "" -> "Sin estado"
else -> humanBackendLabelEs(status)
}
internal fun findingStatusLabelEs(status: String?): String = when (status?.uppercase(Locale.ROOT)) {
"OPEN" -> "Abierto"
"PENDING" -> "Pendiente"
"COMPANY_RESPONSE_PENDING" -> "Pendiente de respuesta de la empresa"
"COMPANY_RESPONDED" -> "Respondido por la empresa"
"VERIFICATION_PENDING" -> "Pendiente de verificación"
"VERIFICATION_SCHEDULED" -> "Verificación programada"
"READY_TO_CLOSE" -> "Listo para cierre"
"CLOSED" -> "Cerrado"
"CANCELLED" -> "Cancelado"
null, "" -> "Sin estado"
else -> humanBackendLabelEs(status)
}
internal fun documentTypeLabelEs(type: String): String = when (type.uppercase(Locale.ROOT)) {
"DNI" -> "DNI"
"CUIL" -> "CUIL"
"PASSPORT" -> "Pasaporte"
"OTHER" -> "Otro"
else -> humanBackendLabelEs(type)
}
internal fun humanBackendLabelEs(value: String): String {
val clean = value.trim().replace('_', ' ').replace('-', ' ').lowercase(Locale("es", "AR"))
return clean.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale("es", "AR")) else it.toString() }
}
@@ -0,0 +1,57 @@
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 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 home = File("src/main/java/com/korexlabs/dhinspeccion/ui/MobileHomeScreen.kt").readText()
private val labels = File("src/main/java/com/korexlabs/dhinspeccion/ui/UiSpanishLabels.kt").readText()
@Test
fun inspectionActFindingFlowMatchesFieldProcedure() {
assertTrue(visit.contains("Iniciar inspección"))
assertTrue(acts.contains("Crear nueva Acta"))
assertTrue(acts.contains("Agregar Hallazgo"))
assertTrue(visit.contains("Instalación"))
assertTrue(visit.contains("Subinstalación"))
assertTrue(visit.contains("Guardar y tomar foto"))
assertTrue(acts.contains("Cerrar Acta y dejar pendiente de firma"))
assertTrue(labels.contains("\"LOCKED\" -> \"Pendiente de firma\""))
}
@Test
fun fieldCreateKeepsDashboardEquivalentMinimumAndCaptureBarrier() {
assertTrue(visit.contains("Nombre técnico *"))
assertTrue(visit.contains("Nombre habitual (opcional)"))
assertTrue(visit.contains("Descripción (opcional)"))
assertTrue(visit.contains("familyReady"))
assertTrue(visit.contains("attributesReady"))
assertTrue(visit.contains("GPS + foto"))
assertTrue(visit.contains("La foto es obligatoria antes de registrar Hallazgos"))
}
@Test
fun structuralSelectionUsesTheServerTypeCode() {
assertTrue(visit.contains("type.code.ifBlank { type.typeName ?: type.name }"))
assertTrue(visit.contains("modernItemTypeCode(it) in setOf(\"instalacion\", \"subinstalacion\")"))
}
@Test
fun currentFieldUiDoesNotExposeBackendEnglishStates() {
assertTrue(home.contains("visitStatusLabelEs(visit.status)"))
assertTrue(findings.contains("findingStatusLabelEs(finding.status)"))
assertTrue(acts.contains("documentTypeLabelEs(kind)"))
assertTrue(labels.contains("\"PASSPORT\" -> \"Pasaporte\""))
assertTrue(labels.contains("\"OTHER\" -> \"Otro\""))
assertFalse(acts.contains("Finalizar y BLOQUEAR Acta"))
assertFalse(acts.contains("Acta BLOQUEADA"))
assertFalse(acts.contains("SELLAR Acta"))
assertFalse(acts.contains("Acta SELLADA"))
assertFalse(acts.contains("Text(\"Email\")"))
}
}
@@ -8,8 +8,8 @@ class ReleaseMetadataTest {
@Test @Test
fun debugBuildKeepsSeparateApplicationIdentity() { fun debugBuildKeepsSeparateApplicationIdentity() {
assertEquals("com.korexlabs.dhinspeccion.debug", BuildConfig.APPLICATION_ID) assertEquals("com.korexlabs.dhinspeccion.debug", BuildConfig.APPLICATION_ID)
assertEquals(27, BuildConfig.VERSION_CODE) assertEquals(28, BuildConfig.VERSION_CODE)
assertEquals("0.18.0-debug", BuildConfig.VERSION_NAME) assertEquals("0.19.0-debug", BuildConfig.VERSION_NAME)
} }
@Test @Test
+2 -2
View File
@@ -10,8 +10,8 @@ function mountedRepoFile(path: string): string {
test('F6.3 Android test cut targets production API and has a distinct installable debug version', () => { test('F6.3 Android test cut targets production API and has a distinct installable debug version', () => {
const gradle = mountedRepoFile('android-app/app/build.gradle.kts'); const gradle = mountedRepoFile('android-app/app/build.gradle.kts');
assert.match(gradle, /versionCode = 27/); assert.match(gradle, /versionCode = 28/);
assert.match(gradle, /versionName = "0\.18\.0"/); assert.match(gradle, /versionName = "0\.19\.0"/);
assert.match(gradle, /https:\/\/dhv2\.korexlabs\.com\/api\/v3\//); assert.match(gradle, /https:\/\/dhv2\.korexlabs\.com\/api\/v3\//);
assert.match(gradle, /applicationIdSuffix = "\.debug"/); assert.match(gradle, /applicationIdSuffix = "\.debug"/);
}); });
+26
View File
@@ -0,0 +1,26 @@
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))));
}
+7 -1
View File
@@ -1,5 +1,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useParams, useSearchParams } from 'react-router'; import { useParams, useSearchParams } from 'react-router';
import { useAuth } from '../auth/AuthContext';
import { hasAdministratorRole } from '../auth/adminAccess';
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback'; import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
import { getAsset } from '../lib/api'; import { getAsset } from '../lib/api';
import type { AssetDetail } from '../lib/api'; import type { AssetDetail } from '../lib/api';
@@ -10,6 +12,7 @@ import { TerritorialInventoryPage, isTerritorialInventoryAsset } from './Territo
export function InventoryDetailPage() { export function InventoryDetailPage() {
const { id } = useParams(); const { id } = useParams();
const [params] = useSearchParams(); const [params] = useSearchParams();
const { user } = useAuth();
const [asset, setAsset] = useState<AssetDetail | null>(null); const [asset, setAsset] = useState<AssetDetail | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
@@ -31,8 +34,11 @@ export function InventoryDetailPage() {
if (loading) return <LoadingBlock label="Cargando registro…" />; if (loading) return <LoadingBlock label="Cargando registro…" />;
if (!asset) return <Alert>{error || 'No se pudo cargar el registro.'}</Alert>; if (!asset) return <Alert>{error || 'No se pudo cargar el registro.'}</Alert>;
const advanced = params.get('advanced') === '1'; const advancedRequested = params.get('advanced') === '1';
const advancedAllowed = hasAdministratorRole(user);
const advanced = advancedRequested && advancedAllowed;
const isCompany = asset.type.code.toLowerCase() === 'empresa'; const isCompany = asset.type.code.toLowerCase() === 'empresa';
if (isCompany && !advanced) { if (isCompany && !advanced) {
return <CompanyInventoryPage initialAsset={asset} />; return <CompanyInventoryPage initialAsset={asset} />;
} }
+39 -10
View File
@@ -2,17 +2,19 @@ import { useEffect, useMemo, useState } from 'react';
import type { FormEvent } from 'react'; import type { FormEvent } from 'react';
import { Link, useSearchParams } from 'react-router'; import { Link, useSearchParams } from 'react-router';
import { useAuth } from '../auth/AuthContext'; import { useAuth } from '../auth/AuthContext';
import { hasAdministratorRole } from '../auth/adminAccess';
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback'; import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
import { Icon } from '../components/Icon'; import { Icon } from '../components/Icon';
import { AssetHistoryPanel } from '../features/assets/AssetHistoryPanel'; import { AssetHistoryPanel } from '../features/assets/AssetHistoryPanel';
import { assetStatusClass, assetStatusLabel } from '../features/assets/assetPresentation'; import { assetStatusClass, assetStatusLabel } from '../features/assets/assetPresentation';
import { import {
getAssetLineage,
listAreaCompanyRelations, listAreaCompanyRelations,
listAssetTreeChildren, listAssetTreeChildren,
listInspectionVisits, listInspectionVisits,
updateAsset, updateAsset,
} from '../lib/api'; } from '../lib/api';
import type { AreaCompanyRelation, AssetDetail, AssetListItem } from '../lib/api'; import type { AreaCompanyRelation, AssetDetail, AssetLineageItem, AssetListItem } from '../lib/api';
import { import {
listInspectionActsGlobalF4, listInspectionActsGlobalF4,
type InspectionActStatusF4, type InspectionActStatusF4,
@@ -31,8 +33,12 @@ function normalized(value: string) {
.trim(); .trim();
} }
function tokensForType(type: { code: string; name: string }) {
return new Set(`${normalized(type.code)} ${normalized(type.name)}`.split(' ').filter(Boolean));
}
function typeTokens(asset: AssetDetail) { function typeTokens(asset: AssetDetail) {
return new Set(`${normalized(asset.type.code)} ${normalized(asset.type.name)}`.split(' ').filter(Boolean)); return tokensForType(asset.type);
} }
export function isTerritorialInventoryAsset(asset: AssetDetail) { export function isTerritorialInventoryAsset(asset: AssetDetail) {
@@ -44,6 +50,14 @@ function isOperationalArea(asset: AssetDetail) {
return typeTokens(asset).has('area'); 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']) { function relationRoleLabel(role: AreaCompanyRelation['relationRole']) {
if (role === 'OPERATOR') return 'Operadora'; if (role === 'OPERATOR') return 'Operadora';
if (role === 'TECHNICAL_OPERATOR') return 'Operadora técnica'; if (role === 'TECHNICAL_OPERATOR') return 'Operadora técnica';
@@ -57,7 +71,7 @@ export function TerritorialInventoryPage({ initialAsset }: { initialAsset: Asset
const [params, setParams] = useSearchParams(); const [params, setParams] = useSearchParams();
const requestedTab = params.get('tab') as TerritoryTab | null; const requestedTab = params.get('tab') as TerritoryTab | null;
const tab: TerritoryTab = requestedTab === 'history' ? 'history' : 'summary'; const tab: TerritoryTab = requestedTab === 'history' ? 'history' : 'summary';
const { hasPermission } = useAuth(); const { user, hasPermission } = useAuth();
const canEdit = hasPermission('assets.update'); const canEdit = hasPermission('assets.update');
const canCreate = hasPermission('assets.create'); const canCreate = hasPermission('assets.create');
const canReadHistory = hasPermission('assets.read_history'); const canReadHistory = hasPermission('assets.read_history');
@@ -65,10 +79,12 @@ export function TerritorialInventoryPage({ initialAsset }: { initialAsset: Asset
const canReadActs = hasPermission('inspection_acts.read'); const canReadActs = hasPermission('inspection_acts.read');
const canReadReports = hasPermission('inspection_reports.read'); const canReadReports = hasPermission('inspection_reports.read');
const canReadInspections = hasPermission('inspections.read'); const canReadInspections = hasPermission('inspections.read');
const canAdvanced = hasAdministratorRole(user);
const [asset, setAsset] = useState(initialAsset); const [asset, setAsset] = useState(initialAsset);
const [children, setChildren] = useState<AssetListItem[]>([]); const [children, setChildren] = useState<AssetListItem[]>([]);
const [childrenHasMore, setChildrenHasMore] = useState(false); const [childrenHasMore, setChildrenHasMore] = useState(false);
const [lineage, setLineage] = useState<AssetLineageItem[]>([]);
const [relations, setRelations] = useState<AreaCompanyRelation[]>([]); const [relations, setRelations] = useState<AreaCompanyRelation[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
@@ -80,18 +96,31 @@ export function TerritorialInventoryPage({ initialAsset }: { initialAsset: Asset
const [metrics, setMetrics] = useState({ activeInspections: 0, acts: 0, reports: 0 }); const [metrics, setMetrics] = useState({ activeInspections: 0, acts: 0, reports: 0 });
const area = isOperationalArea(asset); const area = isOperationalArea(asset);
const yacimiento = isYacimiento(asset);
const activeRelations = useMemo(() => relations.filter((item) => item.active), [relations]); 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 () => { const loadSummary = async () => {
setLoading(true); setLoading(true);
setError(''); setError('');
try { 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([ const [childPage, loadedRelations] = await Promise.all([
listAssetTreeChildren({ parentId: asset.id, limit: 100 }), listAssetTreeChildren({ parentId: asset.id, limit: 100 }),
area && canReadRelations relationAreaId && canReadRelations
? listAreaCompanyRelations({ areaId: asset.id, includeHistory: true }) ? listAreaCompanyRelations({ areaId: relationAreaId, includeHistory: true })
: Promise.resolve([]), : Promise.resolve([]),
]); ]);
setLineage(loadedLineage);
setChildren(childPage.data); setChildren(childPage.data);
setChildrenHasMore(childPage.meta.hasMore); setChildrenHasMore(childPage.meta.hasMore);
setRelations(loadedRelations); setRelations(loadedRelations);
@@ -223,10 +252,10 @@ export function TerritorialInventoryPage({ initialAsset }: { initialAsset: Asset
{childrenHasMore && <div className="territory-card-actions"><Link className="button secondary" to={`/inventarios?section=territory&parentId=${asset.id}`}>Ver contenido completo</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> </article>
{area && canReadRelations && <article className="panel territory-card"> {relationAreaContext && canReadRelations && <article className="panel territory-card">
<div className="territory-card-heading"><div><span className="eyebrow">OPERACIÓN</span><h2>Empresas vinculadas</h2><p>Quién opera o participa actualmente en esta área.</p></div><span className="count-pill">{activeRelations.length}</span></div> <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.</div> : <div className="territory-company-list"> {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)}</small></span><Icon name="chevron" size={16} /></Link>)} {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>} </div>}
</article>} </article>}
@@ -239,7 +268,7 @@ export function TerritorialInventoryPage({ initialAsset }: { initialAsset: Asset
</div> </div>
</article>} </article>}
<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> {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>} </div>}
{tab === 'history' && canReadHistory && <AssetHistoryPanel assetId={asset.id} />} {tab === 'history' && canReadHistory && <AssetHistoryPanel assetId={asset.id} />}