Compare commits

..
Author SHA1 Message Date
admin 8f4f73f16f test(android): impedir borradores de planificación en campo 2026-09-11 11:54:50 -03:00
admin 89219b1cbe fix(android): mostrar sólo inspecciones listas para campo 2026-09-11 11:52:46 -03:00
admin acdf9c8ccd test(android): fijar ausencia y cierre final del Acta 2026-09-11 11:52:06 -03:00
admin 726768bb78 fix(android): cerrar ausencia de firma con outcome ABSENT 2026-09-11 11:50:35 -03:00
admin 01bd7c8454 fix(android): traducir inspecciones en borrador 2026-09-11 11:48:02 -03:00
admin 6363dcd8cc fix(android): resolver ausencia de firma según contrato F4 2026-09-11 11:47:26 -03:00
admin 007033e48f test(api): fijar planificación web y ejecución móvil asignada 2026-09-11 11:38:31 -03:00
admin 551ae4c44b test(api): actualizar contrato Android 0.19.0 2026-09-11 11:38:19 -03:00
admin 4f0c367d3e docs(android): documentar candidata final de campo 0.19.0 2026-09-11 11:35:30 -03:00
admin ebce71653f test(android): actualizar metadata de 0.19.0 2026-09-11 11:35:03 -03:00
admin 97a4da535a fix(android): mantener compatibilidad de pantallas heredadas al compilar 2026-09-11 11:34:46 -03:00
admin 006f9513d0 test(android): ajustar contrato de navegación de Inventario 2026-09-11 11:32:32 -03:00
admin 7fa2ff03fa test(android): fijar contrato final de campo y castellano 2026-09-11 11:32:10 -03:00
admin 5ebff1cd19 chore(android): versionar candidata final 0.19.0 2026-09-11 11:31:51 -03:00
admin fa20c60a94 fix(android): castellanizar Hallazgos de campo 2026-09-11 11:31:24 -03:00
admin 9ace5b8e46 feat(android): completar alta de Inventario de campo en castellano 2026-09-11 11:30:22 -03:00
admin 752bd4d6db fix(android): alinear alta de campo y cierre pendiente de firma 2026-09-11 11:28:29 -03:00
admin 102b9f4d2f feat(android): aplicar marca Mendoza y castellanizar acceso 2026-09-11 11:27:18 -03:00
admin 10ceedae11 feat(android): incorporar marca oficial Mendoza 2026-09-11 11:26:44 -03:00
admin 0fbb37c153 fix(android): cerrar actas como pendientes de firma y castellanizar flujo 2026-09-11 11:22:12 -03:00
admin 310fd87aeb feat(android): cerrar inicio de campo a inspecciones planificadas 2026-09-11 11:20:55 -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 444 additions and 257 deletions
+41 -23
View File
@@ -1,40 +1,58 @@
# DH Inspección Android · release Campo Moderno 0.18.0
# DH Inspección Android · release de campo 0.19.0
## Candidata vigente
- Fase funcional: **Campo Moderno / UX dinámica de Inventario y Actas**.
- `versionName`: **0.18.0**.
- `versionCode`: **27**.
- Fase funcional: **Campo final / Inspección → Acta → Inventario → Hallazgos → Firma**.
- `versionName`: **0.19.0**.
- `versionCode`: **28**.
- Application ID release: `com.korexlabs.dhinspeccion`.
- Application ID debug/QA: `com.korexlabs.dhinspeccion.debug`.
- 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.
## Novedades de campo 0.18.0
## Procedimiento de campo 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.
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.
La carga de una Subinstalación mantiene la integridad del modelo físico pero reduce pasos:
## Castellano y presentación
1. el buscador de **Instalaciones padre** ahora consulta mientras el inspector escribe;
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.
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.
Se conserva la regla de que un elemento nacido en campo no puede recibir Hallazgos hasta contar con GPS y fotografía.
También se incorporó la marca oficial de Mendoza provista para esta release en las pantallas de acceso y desbloqueo biométrico.
## Actas en Android
## Integridad del alta de Inventario
El espacio de Actas fue rediseñado para mostrar con claridad:
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.
- contexto de la Inspección y Yacimiento;
- estado de cada Acta y cantidad de Hallazgos;
- urgencia mediante selección explícita;
- responsable de empresa, bloqueo, firmas, manifestación y sellado;
- condición necesaria para cerrar la Inspección.
El servidor continúa siendo la autoridad para:
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.
- 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
@@ -42,12 +60,12 @@ 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;
2. Android lint;
3. unit tests Android reales, con verificación de que exista al menos una prueba ejecutada;
3. unit tests Android reales, incluyendo contratos del procedimiento final;
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 promover el cambio.
Además, `DH V2 CI` debe mantener verdes API, WEB y contrato Docker/migraciones antes de incorporar la release a `main`.
## Firma release
@@ -62,5 +80,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 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 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;
- 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 = 27
versionName = "0.18.0"
versionCode = 28
versionName = "0.19.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true
@@ -161,7 +161,7 @@ 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")) {
@@ -169,7 +169,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
return
}
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 borrador. Cerrala antes de crear la siguiente."
return
}
launchBusy {
@@ -218,7 +218,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,6 +237,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
familyId: String?,
name: String,
commonName: String?,
description: String?,
discoveryNotes: String?,
attributes: Map<String, Any?>,
latitude: Double,
longitude: Double,
@@ -244,7 +246,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) {
@@ -258,6 +260,8 @@ 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,
@@ -265,7 +269,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
deviceCapturedAt = Instant.now().toString(),
)
selectedFieldAsset = repository.createFieldAsset(visitId, request)
notice = "Inventario creado con GPS. Falta la fotografía obligatoria. Si ya existía, podés fusionarlo antes de continuar."
notice = "Inventario creado con ubicación. Falta la fotografía obligatoria. Si ya existía, podés fusionarlo antes de continuar."
reloadCurrentInventory(visitId)
}
}
@@ -319,7 +323,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
)
selectedFieldAsset = selectedFieldAsset?.copy(capture = response.capture)
notice = if (response.capture.readyForFinding) {
"Captura completa: GPS y fotografía registrados."
"Captura completa: ubicación y fotografía registradas."
} else {
"Fotografía registrada."
}
@@ -419,7 +423,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
description = description,
)
loadEvidenceInternal(findingId)
notice = "Evidencia fotográfica registrada con GPS."
notice = "Evidencia fotográfica registrada con ubicación."
}
}
@@ -471,7 +475,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
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. Al cerrar el Acta, dejá asentada la ausencia en el circuito de firma."
}
}
@@ -480,12 +484,12 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
launchBusy {
actClosure = actsRepository.lock(actId)
refreshSelectedActInternal(actId)
notice = "Acta bloqueada. Su contenido quedó inmutable; ahora deben resolverse las firmas y manifestaciones."
notice = "Acta cerrada y pendiente de firma. El contenido y los Hallazgos quedaron inmutables."
}
}
fun reopenSelectedAct() {
error = "Un Acta bloqueada es inmutable y no puede volver a borrador."
error = "Un Acta cerrada y pendiente de firma es inmutable y no puede volver a borrador."
}
fun signSelectedActAsInspector(
@@ -524,8 +528,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun recordCompanyOutcome(status: String, reason: String) {
val actId = selectedAct?.id ?: return
if (status != "REFUSED") {
error = "La ausencia no resuelve la manifestación de la empresa. Registrá una firma o una negativa."
if (status !in setOf("REFUSED", "ABSENT")) {
error = "La manifestación de la empresa debe resolverse con firma, negativa o ausencia documentada."
return
}
if (reason.trim().length < 10) {
@@ -534,7 +538,11 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
}
launchBusy {
actClosure = actsRepository.companyOutcome(actId, status, reason)
notice = "Negativa a firmar asentada."
notice = if (status == "ABSENT") {
"Ausencia para la firma de empresa asentada."
} else {
"Negativa a firmar asentada."
}
}
}
@@ -546,7 +554,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
refreshSelectedActInternal(actId)
loadActsInternal(currentVisit.id, selectDraft = false)
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, finalizada e inmutable. Podés crear otra Acta o continuar hacia el cierre de la inspección."
}
}
@@ -555,7 +563,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
launchBusy {
visit = actsRepository.closeVisit(visitId)
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.code} · Acta ${options.act.code}", style = MaterialTheme.typography.bodySmall)
Text(
"GPS + foto del Inventario: ${if (options.capture.readyForFinding) "OK" else "pendiente"}",
"Ubicación y foto del Inventario: ${if (options.capture.readyForFinding) "completas" else "pendientes"}",
color = if (options.capture.readyForFinding) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
@@ -210,7 +210,10 @@ 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 ?: "s/d"} · ${finding.status}", style = MaterialTheme.typography.bodySmall)
Text(
"Gravedad: ${finding.severity ?: "sin definir"} · ${findingStatusLabel(finding.status)}",
style = MaterialTheme.typography.bodySmall,
)
Text(
"Evidencias: ${evidence.size} · Fotos: ${evidence.count { it.kind == "PHOTO" }}",
style = MaterialTheme.typography.bodySmall,
@@ -226,7 +229,7 @@ fun FieldFindingScreen(model: MainViewModel) {
enabled = !model.busy,
modifier = Modifier.fillMaxWidth(),
) {
Text("Tomar foto con GPS")
Text("Tomar foto con ubicación")
}
}
}
@@ -366,7 +369,7 @@ fun FieldFindingScreen(model: MainViewModel) {
enabled = !model.busy,
modifier = Modifier.fillMaxWidth(),
) {
Text("Tomar foto con GPS")
Text("Tomar foto con ubicación")
}
Text("También podés registrar otro Hallazgo sobre el mismo Inventario.", style = MaterialTheme.typography.bodySmall)
}
@@ -375,6 +378,17 @@ 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
@@ -393,7 +407,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 GPS actual."))
if (location == null) continuation.resumeWithException(IllegalStateException("No se pudo obtener una ubicación actual."))
else continuation.resume(FindingGeoSnapshot(location.latitude, location.longitude, location.accuracy.toDouble()))
}
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
@@ -0,0 +1,32 @@
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,12 +3,14 @@ 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
@@ -30,6 +32,7 @@ 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
@@ -37,6 +40,7 @@ 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"
@@ -98,6 +102,23 @@ fun DhRoot(model: MainViewModel, activity: FragmentActivity) {
}
}
@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("") }
@@ -106,14 +127,13 @@ private fun EnhancedLoginScreen(model: MainViewModel, onPasswordLogin: () -> Uni
Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(14.dp)) {
Text("DH Inspección", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
Text("Aplicación de campo · Dirección de Hidrocarburos")
MendozaBrand()
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 email") },
label = { Text("Usuario o correo electrónico") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
@@ -198,6 +218,11 @@ 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) }
@@ -11,79 +11,30 @@ 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.material3.AssistChip
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Assignment
import androidx.compose.material.icons.filled.Refresh
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 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()
}
val fieldVisits = model.visits.filter { it.status != "DRAFT" }
Column(Modifier.fillMaxSize().padding(top = 28.dp)) {
Row(
@@ -91,16 +42,18 @@ fun MobileHomeScreen(model: MainViewModel) {
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column {
Column(Modifier.weight(1f)) {
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 && !opening) {
OutlinedButton(onClick = { model.loadVisits() }, enabled = !model.busy) {
Icon(Icons.Filled.Refresh, contentDescription = null)
Spacer(Modifier.width(6.dp))
Text("Actualizar")
}
Spacer(Modifier.width(8.dp))
OutlinedButton(onClick = { model.logout() }, enabled = !opening) { Text("Salir") }
OutlinedButton(onClick = { model.logout() }) { Text("Salir") }
}
}
@@ -110,97 +63,49 @@ 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) }
Button(
onClick = {
showOpen = !showOpen
localError = null
},
Surface(
modifier = Modifier.fillMaxWidth(),
enabled = !opening,
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.secondaryContainer,
) {
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),
) {
Text("Abrir inspección en campo", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Row(
Modifier.padding(14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = 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(
"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.",
"Acá aparecen las inspecciones ya planificadas y asignadas desde el tablero. La visita se inicia únicamente al llegar al lugar.",
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 && model.visits.isEmpty()) {
if (model.busy && fieldVisits.isEmpty()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
} 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 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 {
LazyColumn(
Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 10.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
items(model.visits, key = { it.id }) { visit ->
items(fieldVisits, key = { it.id }) { visit ->
MobileVisitCard(visit) { model.openVisit(visit.id) }
}
item { Spacer(Modifier.height(24.dp)) }
@@ -211,21 +116,33 @@ fun MobileHomeScreen(model: MainViewModel) {
@Composable
private fun MobileVisitCard(visit: VisitSummary, onOpen: () -> Unit) {
Card(onClick = onOpen, modifier = Modifier.fillMaxWidth()) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
ElevatedCard(onClick = onOpen, modifier = Modifier.fillMaxWidth()) {
Column(Modifier.padding(15.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(visit.code, fontWeight = FontWeight.Bold)
Text(visit.status)
StatusPill(mobileVisitStatusLabel(visit.status))
}
Text(visit.scopeAsset?.name ?: visit.operationalArea?.name ?: "Alcance sin informar", fontWeight = FontWeight.SemiBold)
Text(visit.operatorCompany?.name ?: "Operadora sin definir")
Text(visit.operationalArea?.name ?: "Área sin definir", style = MaterialTheme.typography.bodySmall)
visit.operationalArea?.name?.takeIf { it != visit.scopeAsset?.name }?.let {
Text("Área · $it", style = MaterialTheme.typography.bodySmall)
}
visit.plannedStartAt?.let {
Text("Prevista: ${it.replace('T', ' ').take(16)}", style = MaterialTheme.typography.bodySmall)
Text("Planificada · ${it.replace('T', ' ').take(16)}", style = MaterialTheme.typography.bodySmall)
}
Text(
"Inventario: ${visit.assetCount} · Equipo inspector: ${visit.memberCount}",
"Inventario previsto: ${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"
}
@@ -245,7 +245,7 @@ fun ModernMobileActsScreen(
) {
Text(
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á 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."
},
@@ -319,12 +319,16 @@ fun ModernMobileActsScreen(
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(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("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)
Button(
onClick = { model.setCompanyResponsiblePresent(fullName, documentType, documentNumber, position, email, phone) },
@@ -353,17 +357,24 @@ fun ModernMobileActsScreen(
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Filled.Lock, null, tint = MaterialTheme.colorScheme.error)
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(
"Al bloquear el Acta, su contenido y sus Hallazgos quedan inmutables.",
"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("Finalizar y BLOQUEAR Acta") }
) { 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,
)
}
}
}
}
@@ -372,17 +383,17 @@ fun ModernMobileActsScreen(
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 == "SIGNED" || companyOutcome?.status == "REFUSED"
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 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.")
closure?.closure?.preparedSha256?.let { Text("Hash · $it", style = MaterialTheme.typography.bodySmall) }
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)
@@ -402,23 +413,29 @@ fun ModernMobileActsScreen(
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"
else -> companyOutcome.status
"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") {
Surface(Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.errorContainer) {
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.error)
Text(
"La ausencia no resuelve la manifestación. Debe registrarse firma o negativa antes de sellar.",
Modifier.weight(1f),
style = MaterialTheme.typography.bodySmall,
)
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)) {
@@ -462,11 +479,11 @@ fun ModernMobileActsScreen(
) {
Icon(Icons.Filled.CheckCircle, null)
Spacer(Modifier.width(6.dp))
Text("SELLAR Acta definitivamente")
Text("Finalizar Acta")
}
} else {
Text(
"La Inspección no puede cerrarse hasta completar las manifestaciones de esta Acta.",
"La inspección no puede cerrarse hasta resolver la firma, negativa o ausencia de esta Acta.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -480,9 +497,9 @@ fun ModernMobileActsScreen(
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 SELLADA e inmutable", fontWeight = FontWeight.Bold)
Text("Desde este sellado se genera el PDF del Acta y el INF Word editable para el Inspector.")
closure?.closure?.finalSha256?.let { Text("SHA-256 · $it", style = MaterialTheme.typography.bodySmall) }
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) }
}
}
}
@@ -501,12 +518,12 @@ fun ModernMobileActsScreen(
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)
Text("Finalizar inspección", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
if (pendingActs.isEmpty()) {
SuccessLine("Todas las Actas están SELLADAS")
SuccessLine("Todas las Actas están finalizadas")
} else {
Text(
"Falta SELLAR: ${pendingActs.joinToString { it.code }}",
"Actas pendientes de firma o finalización: ${pendingActs.joinToString { it.code }}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
@@ -515,7 +532,7 @@ fun ModernMobileActsScreen(
onClick = { model.closeInspection() },
enabled = !model.busy && activeActs.isNotEmpty() && pendingActs.isEmpty(),
modifier = Modifier.fillMaxWidth(),
) { Text("Cerrar Inspección y salir") }
) { Text("Cerrar inspección y salir") }
}
}
}
@@ -531,12 +548,20 @@ private fun SuccessLine(text: String) {
}
}
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" -> "Bloqueada"
"SEALED" -> "Sellada"
"LOCKED" -> "Pendiente de firma"
"SEALED" -> "Finalizada"
"CANCELLED" -> "Cancelada"
else -> status
else -> "Estado no reconocido"
}
private fun hasModernActLocation(context: Context): Boolean =
@@ -262,7 +262,7 @@ private fun ModernChecklistCard(visit: VisitDetail) {
val checklist = visit.checklist
ElevatedCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text("Antecedentes", fontWeight = FontWeight.Bold)
Text("Antecedentes y controles", fontWeight = FontWeight.Bold)
Text(
"${checklist.antecedents} antecedentes · ${checklist.upcomingControls} próximos controles",
color = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -274,7 +274,7 @@ private fun ModernChecklistCard(visit: VisitDetail) {
)
}
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,8 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
var parentLabel by rememberSaveable(visit.id) { mutableStateOf("") }
var name by rememberSaveable(visit.id) { mutableStateOf("") }
var commonName by rememberSaveable(visit.id) { mutableStateOf("") }
var description by rememberSaveable(visit.id) { mutableStateOf("") }
var discoveryNotes 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("") }
@@ -311,6 +313,8 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
fun resetForm() {
name = ""
commonName = ""
description = ""
discoveryNotes = ""
selectedTypeId = null
selectedFamilyId = null
familySearch = ""
@@ -427,7 +431,7 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
pendingPhotoGeo = geo
takePicture.launch(uri)
}
.onFailure { localError = it.message ?: "No se pudo obtener el GPS para la fotografía." }
.onFailure { localError = it.message ?: "No se pudo obtener la ubicación para la fotografía." }
}
}
@@ -444,13 +448,15 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
familyId = selectedFamilyId,
name = name,
commonName = commonName,
description = description,
discoveryNotes = discoveryNotes,
attributes = buildModernAttributes(type, attributeValues),
latitude = geo.latitude,
longitude = geo.longitude,
accuracyM = geo.accuracyM,
)
}
.onFailure { localError = it.message ?: "No se pudo capturar la ubicación GPS." }
.onFailure { localError = it.message ?: "No se pudo capturar la ubicación actual." }
}
}
@@ -800,8 +806,8 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Nombre o código visible *") },
supportingText = { Text("Usá lo que figura en placa o identifica el elemento en campo.") },
label = { Text("Nombre técnico / identificación *") },
supportingText = { Text("Usá lo que figura en placa o identifica el elemento. El Código DH de campo se genera automáticamente.") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
@@ -812,6 +818,21 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
OutlinedTextField(
value = description,
onValueChange = { description = it },
label = { Text("Descripción (opcional)") },
modifier = Modifier.fillMaxWidth(),
minLines = 2,
)
OutlinedTextField(
value = discoveryNotes,
onValueChange = { discoveryNotes = it },
label = { Text("Observaciones del alta (opcional)") },
supportingText = { Text("Anotá datos útiles para la revisión posterior en oficina.") },
modifier = Modifier.fillMaxWidth(),
minLines = 2,
)
selectedType?.attributes?.forEach { definition ->
OutlinedTextField(
@@ -830,12 +851,12 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
}
HorizontalDivider()
ModernStepHeader(if (isSubinstallation) 3 else 2, "GPS + foto")
ModernStepHeader(if (isSubinstallation) 3 else 2, "Ubicación y foto")
Surface(Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.secondaryContainer) {
Row(Modifier.padding(14.dp), horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Filled.CameraAlt, null, tint = MaterialTheme.colorScheme.secondary)
Text(
"Al guardar capturamos el GPS y abrimos la cámara. La foto es obligatoria antes de registrar Hallazgos.",
"Al guardar capturamos la ubicación y abrimos la cámara. La foto es obligatoria antes de registrar Hallazgos.",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.weight(1f),
)
@@ -927,7 +948,7 @@ private fun ModernInventoryCard(item: FieldInventoryItem, onInspect: () -> Unit,
}
item.commonName?.takeIf { it.isNotBlank() }?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
Text(
if (item.readyForFinding) "Listo para Hallazgos" else "Falta GPS / foto",
if (item.readyForFinding) "Listo para Hallazgos" else "Falta ubicación / foto",
color = if (item.readyForFinding) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.SemiBold,
@@ -966,7 +987,7 @@ private fun ModernCaptureCard(
IconButton(onClick = onClose) { Icon(Icons.Filled.Close, "Cerrar") }
}
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Text(if (gps) "GPS" else "GPS pendiente", color = if (gps) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error)
Text(if (gps) "Ubicación" else "Ubicación pendiente", color = if (gps) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error)
Text("Fotos · $photos")
}
if (!ready) {
@@ -1078,7 +1099,7 @@ private fun modernStatusLabel(status: String): String = when (status) {
"IN_PROGRESS" -> "En curso"
"CLOSED" -> "Cerrada"
"CANCELLED" -> "Cancelada"
else -> status
else -> "Estado no reconocido"
}
private fun buildModernAttributes(type: FieldType, values: Map<String, String>): Map<String, Any?> =
@@ -1112,7 +1133,7 @@ private suspend fun currentModernGeo(context: Context): ModernGeoSnapshot = susp
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."))
if (location == null) continuation.resumeWithException(IllegalStateException("No se pudo obtener una ubicación actual."))
else continuation.resume(ModernGeoSnapshot(location.latitude, location.longitude, location.accuracy.toDouble()))
}
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

@@ -0,0 +1,68 @@
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(27, BuildConfig.VERSION_CODE)
assertEquals("0.18.0-debug", BuildConfig.VERSION_NAME)
assertEquals(28, BuildConfig.VERSION_CODE)
assertEquals("0.19.0-debug", BuildConfig.VERSION_NAME)
}
@Test
+3 -3
View File
@@ -7,11 +7,11 @@ function mountedRepoFile(path: string): string {
return readFileSync(resolve(process.cwd(), '..', path), 'utf8');
}
test('F6.3 Android test cut targets production API and has a distinct installable debug version', () => {
test('Android 0.19.0 targets production API and keeps a distinct installable debug identity', () => {
const gradle = mountedRepoFile('android-app/app/build.gradle.kts');
assert.match(gradle, /versionCode = 27/);
assert.match(gradle, /versionName = "0\.18\.0"/);
assert.match(gradle, /versionCode = 28/);
assert.match(gradle, /versionName = "0\.19\.0"/);
assert.match(gradle, /https:\/\/dhv2\.korexlabs\.com\/api\/v3\//);
assert.match(gradle, /applicationIdSuffix = "\.debug"/);
});
@@ -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('F6.1 exposes mobile planning context and opens an inspection self-assigned to the current inspector', () => {
test('legacy mobile-open endpoints remain available without becoming the active Android workflow', () => {
const controller = source('src/inspection-visits/inspection-visits.controller.ts');
const dto = source('src/inspection-visits/dto/open-mobile-inspection.dto.ts');
@@ -45,23 +45,21 @@ test('F6.1 exposes mobile planning context and opens an inspection self-assigned
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('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');
test('Android lists inspections assigned from office and starts them only from the visit screen', () => {
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(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(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(gate, /else -> MobileHomeScreen\(model\)/);
});
+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 { 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';
@@ -10,6 +12,7 @@ import { TerritorialInventoryPage, isTerritorialInventoryAsset } from './Territo
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('');
@@ -31,8 +34,11 @@ export function InventoryDetailPage() {
if (loading) return <LoadingBlock label="Cargando registro…" />;
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';
if (isCompany && !advanced) {
return <CompanyInventoryPage initialAsset={asset} />;
}
+39 -10
View File
@@ -2,17 +2,19 @@ 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, AssetListItem } from '../lib/api';
import type { AreaCompanyRelation, AssetDetail, AssetLineageItem, AssetListItem } from '../lib/api';
import {
listInspectionActsGlobalF4,
type InspectionActStatusF4,
@@ -31,8 +33,12 @@ function normalized(value: string) {
.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 new Set(`${normalized(asset.type.code)} ${normalized(asset.type.name)}`.split(' ').filter(Boolean));
return tokensForType(asset.type);
}
export function isTerritorialInventoryAsset(asset: AssetDetail) {
@@ -44,6 +50,14 @@ 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';
@@ -57,7 +71,7 @@ export function TerritorialInventoryPage({ initialAsset }: { initialAsset: Asset
const [params, setParams] = useSearchParams();
const requestedTab = params.get('tab') as TerritoryTab | null;
const tab: TerritoryTab = requestedTab === 'history' ? 'history' : 'summary';
const { hasPermission } = useAuth();
const { user, hasPermission } = useAuth();
const canEdit = hasPermission('assets.update');
const canCreate = hasPermission('assets.create');
const canReadHistory = hasPermission('assets.read_history');
@@ -65,10 +79,12 @@ export function TerritorialInventoryPage({ initialAsset }: { initialAsset: Asset
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('');
@@ -80,18 +96,31 @@ export function TerritorialInventoryPage({ initialAsset }: { initialAsset: Asset
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 }),
area && canReadRelations
? listAreaCompanyRelations({ areaId: asset.id, includeHistory: true })
relationAreaId && canReadRelations
? listAreaCompanyRelations({ areaId: relationAreaId, includeHistory: true })
: Promise.resolve([]),
]);
setLineage(loadedLineage);
setChildren(childPage.data);
setChildrenHasMore(childPage.meta.hasMore);
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>}
</article>
{area && 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>
{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">
{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>)}
{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>}
@@ -239,7 +268,7 @@ export function TerritorialInventoryPage({ initialAsset }: { initialAsset: Asset
</div>
</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>}
{tab === 'history' && canReadHistory && <AssetHistoryPanel assetId={asset.id} />}