Compare commits

...
Author SHA1 Message Date
DH V2 9eef156197 fix(field): repair finding creation and simplify mobile UX
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 3m17s
DH V2 CI / API · typecheck, tests, build (push) Successful in 35s
DH V2 CI / WEB · typecheck, build (push) Successful in 20s
Production dependency audit / API · production dependencies (push) Successful in 9s
Production dependency audit / WEB · production dependencies (push) Successful in 9s
DH V2 CI / Docker / scripts contract (push) Successful in 1m7s
2026-09-14 23:24:41 -03:00
DH V2 08f164a209 fix(web): package Mendoza favicon in production image
DH V2 CI / API · typecheck, tests, build (push) Successful in 34s
DH V2 CI / Docker / scripts contract (push) Successful in 1m2s
DH V2 CI / WEB · typecheck, build (push) Successful in 20s
Production dependency audit / API · production dependencies (push) Successful in 9s
Production dependency audit / WEB · production dependencies (push) Successful in 9s
2026-09-14 19:36:36 -03:00
DH V2 23450bb6d0 fix(field): repair installation creation and streamline mobile form
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 3m4s
DH V2 CI / API · typecheck, tests, build (push) Successful in 32s
DH V2 CI / WEB · typecheck, build (push) Successful in 18s
Production dependency audit / WEB · production dependencies (push) Successful in 9s
DH V2 CI / Docker / scripts contract (push) Successful in 1m0s
Production dependency audit / API · production dependencies (push) Successful in 9s
2026-09-14 19:25:20 -03:00
admin 20ccba5292 Android 0.19.5: tipo de instalación y alta corregida (#2)
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 3m15s
DH V2 CI / API · typecheck, tests, build (push) Successful in 32s
DH V2 CI / WEB · typecheck, build (push) Successful in 19s
Production dependency audit / API · production dependencies (push) Successful in 9s
Production dependency audit / WEB · production dependencies (push) Successful in 8s
DH V2 CI / Docker / scripts contract (push) Successful in 51s
Unifica Tipo de instalación en un desplegable con buscador y corrige el payload de atributos técnicos para usar IDs de definición. Android CI: 26 pruebas, lint y compilaciones debug/release aprobadas. CI general y auditoría de dependencias aprobadas.
2026-09-14 18:48:26 -03:00
26 changed files with 264 additions and 95 deletions
+9 -5
View File
@@ -1,20 +1,20 @@
# DH Inspección Android · release final de campo 0.19.3 # DH Inspección Android · release final de campo 0.19.7
## Candidata vigente ## Candidata vigente
- Fase funcional: **Flujo final de campo · Inspección → Acta → Hallazgos → Firma**. - Fase funcional: **Flujo final de campo · Inspección → Acta → Hallazgos → Firma**.
- `versionName`: **0.19.3**. - `versionName`: **0.19.7**.
- `versionCode`: **31**. - `versionCode`: **35**.
- 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/`.
- Servidor compatible de esta candidata: **API 0.29.0-4 / WEB 0.23.0-3**. - Servidor compatible de esta candidata: **API 0.29.0-5 / WEB 0.23.0-3**.
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.
## Procedimiento operativo validado ## Procedimiento operativo validado
La APK 0.19.3 fija como recorrido principal de campo: La APK 0.19.7 fija como recorrido principal de campo:
1. **Iniciar Inspección**. Al iniciarla se habilita el circuito de **Actas y Hallazgos**; Inventario no es una acción independiente de la Inspección. 1. **Iniciar Inspección**. Al iniciarla se habilita el circuito de **Actas y Hallazgos**; Inventario no es una acción independiente de la Inspección.
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. La urgencia todavía no se define. 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. La urgencia todavía no se define.
@@ -51,6 +51,10 @@ Además de lo anterior requiere elegir primero la **Instalación padre**. El bus
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. 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.
## Alta de Hallazgos
El alta de Hallazgos usa un selector desplegable con búsqueda por nombre. La pantalla muestra sólo la información necesaria para decidir, mantiene **Otro / No está en la lista** como salida manual y evita exponer códigos o leyendas internas del catálogo. La fecha de corrección no se solicita al inspector al crear un Hallazgo; los plazos administrativos se gestionan fuera de este alta de campo. Los campos de carga son compactos y el teclado avanza con **Siguiente** entre ellos.
## Actas y firma ## Actas y firma
La terminología visible se simplifica: La terminología visible se simplifica:
+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 = 33 versionCode = 35
versionName = "0.19.5" versionName = "0.19.7"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true vectorDrawables.useSupportLibrary = true
@@ -316,7 +316,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
) )
selectedFieldAsset = selectedFieldAsset?.copy(capture = response.capture) selectedFieldAsset = selectedFieldAsset?.copy(capture = response.capture)
notice = if (response.capture.readyForFinding) { notice = if (response.capture.readyForFinding) {
"Captura completa: GPS y fotografía registrados." "Inventario listo: ubicación y foto registradas."
} else { } else {
"Fotografía registrada." "Fotografía registrada."
} }
@@ -353,7 +353,6 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
customLegalBasis: String?, customLegalBasis: String?,
description: String, description: String,
severity: Int?, severity: Int?,
correctionDueOn: String?,
) { ) {
val visitId = visit?.id ?: return val visitId = visit?.id ?: return
val assetId = selectedFieldAsset?.asset?.id ?: return val assetId = selectedFieldAsset?.asset?.id ?: return
@@ -386,7 +385,6 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
customLegalBasis = customLegalBasis?.trim()?.takeIf { it.isNotBlank() }, customLegalBasis = customLegalBasis?.trim()?.takeIf { it.isNotBlank() },
description = description.trim(), description = description.trim(),
severity = severity, severity = severity,
correctionDueOn = correctionDueOn?.trim()?.takeIf { it.isNotBlank() },
), ),
) )
lastCreatedFinding = response.finding lastCreatedFinding = response.finding
@@ -561,8 +561,10 @@ class DhRepository(context: Context) {
if (error is HttpException) { if (error is HttpException) {
val body = runCatching { error.response()?.errorBody()?.string() }.getOrNull() val body = runCatching { error.response()?.errorBody()?.string() }.getOrNull()
val message = runCatching { JSONObject(body.orEmpty()).optString("message") }.getOrNull() val message = runCatching { JSONObject(body.orEmpty()).optString("message") }.getOrNull()
if (message == "Error interno") return "No se pudo completar la operación. Intentá nuevamente."
if (!message.isNullOrBlank()) return message if (!message.isNullOrBlank()) return message
return "Error HTTP ${error.code()}" if (error.code() >= 500) return "No se pudo completar la operación. Intentá nuevamente."
return "No se pudo procesar la solicitud (${error.code()})."
} }
return error.message ?: "Ocurrió un error inesperado" return error.message ?: "Ocurrió un error inesperado"
} }
@@ -113,7 +113,6 @@ data class CreateFieldFindingRequest(
val customLegalBasis: String? = null, val customLegalBasis: String? = null,
val description: String, val description: String,
val severity: Int? = null, val severity: Int? = null,
val correctionDueOn: String? = null,
) )
data class FieldFindingCreateResponse( data class FieldFindingCreateResponse(
@@ -7,21 +7,27 @@ import android.net.Uri
import android.os.Environment import android.os.Environment
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
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.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MenuAnchorType
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -34,8 +40,12 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
@@ -60,20 +70,23 @@ private data class FindingGeoSnapshot(
val accuracyM: Double?, val accuracyM: Double?,
) )
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun FieldFindingScreen(model: MainViewModel) { fun FieldFindingScreen(model: MainViewModel) {
val options = model.fieldFindingOptions ?: return val options = model.fieldFindingOptions ?: return
val asset = model.selectedFieldAsset?.asset ?: return val asset = model.selectedFieldAsset?.asset ?: return
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val focusManager = LocalFocusManager.current
val keyboard = LocalSoftwareKeyboardController.current
var search by rememberSaveable(asset.id) { mutableStateOf("") } var search by rememberSaveable(asset.id) { mutableStateOf("") }
var catalogExpanded by rememberSaveable(asset.id) { mutableStateOf(false) }
var selectedCatalogId by rememberSaveable(asset.id) { mutableStateOf<String?>(null) } var selectedCatalogId by rememberSaveable(asset.id) { mutableStateOf<String?>(null) }
var other by rememberSaveable(asset.id) { mutableStateOf(false) } var other by rememberSaveable(asset.id) { mutableStateOf(false) }
var customTitle by rememberSaveable(asset.id) { mutableStateOf("") } var customTitle by rememberSaveable(asset.id) { mutableStateOf("") }
var customLegalBasis by rememberSaveable(asset.id) { mutableStateOf("") } var customLegalBasis by rememberSaveable(asset.id) { mutableStateOf("") }
var description by rememberSaveable(asset.id) { mutableStateOf("") } var description by rememberSaveable(asset.id) { mutableStateOf("") }
var severityText by rememberSaveable(asset.id) { mutableStateOf("") } var severityText by rememberSaveable(asset.id) { mutableStateOf("") }
var correctionDueOn by rememberSaveable(asset.id) { mutableStateOf("") }
var localError by rememberSaveable { mutableStateOf<String?>(null) } var localError by rememberSaveable { mutableStateOf<String?>(null) }
var requestedFindingId by remember { mutableStateOf<String?>(null) } var requestedFindingId by remember { mutableStateOf<String?>(null) }
@@ -164,8 +177,8 @@ fun FieldFindingScreen(model: MainViewModel) {
customLegalBasis = "" customLegalBasis = ""
description = "" description = ""
severityText = "" severityText = ""
correctionDueOn = ""
search = "" search = ""
catalogExpanded = false
} }
} }
@@ -180,15 +193,15 @@ fun FieldFindingScreen(model: MainViewModel) {
OutlinedButton(onClick = { model.clearFindingFlow() }, enabled = !model.busy) { OutlinedButton(onClick = { model.clearFindingFlow() }, enabled = !model.busy) {
Text("Volver") Text("Volver")
} }
Text("Hallazgo de campo", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) Text("Nuevo Hallazgo", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
} }
Card(Modifier.fillMaxWidth()) { Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
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} · ${options.act.code}", style = MaterialTheme.typography.bodySmall)
Text( Text(
"GPS + foto del Inventario: ${if (options.capture.readyForFinding) "completo" else "pendiente"}", if (options.capture.readyForFinding) "Inventario listo para registrar Hallazgos" else "Completá GPS y foto antes de continuar",
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,
) )
@@ -238,74 +251,105 @@ fun FieldFindingScreen(model: MainViewModel) {
HorizontalDivider() HorizontalDivider()
} }
Text("1. Elegí el tipo de Hallazgo", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) Text("1. Tipo de Hallazgo", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
if (!options.catalog.typeConfigured) { if (!options.catalog.typeConfigured) {
Text( Text(
options.catalog.configurationReason "No hay tipos sugeridos para este Inventario. Elegí Otro para cargarlo manualmente.",
?: "Este tipo de Inventario todavía no tiene un catálogo contextual configurado. Podés usar OTROS.", color = MaterialTheme.colorScheme.onSurfaceVariant,
color = MaterialTheme.colorScheme.secondary, style = MaterialTheme.typography.bodySmall,
) )
} }
OutlinedTextField( ExposedDropdownMenuBox(
value = search, expanded = catalogExpanded && !model.busy,
onValueChange = { search = it }, onExpandedChange = {
label = { Text("Buscar en catálogo") }, if (!model.busy) {
modifier = Modifier.fillMaxWidth(), catalogExpanded = it
singleLine = true, if (it) search = ""
) }
},
filtered.forEach { item -> ) {
val chosen = !other && selectedCatalogId == item.id OutlinedTextField(
Card( value = if (catalogExpanded) search else when {
modifier = Modifier.fillMaxWidth().clickable { other -> "Otro / No está en la lista"
selectedCatalogId = item.id selected != null -> selected.title
other = false else -> ""
severityText = item.suggestedSeverity?.toString().orEmpty()
},
colors = if (chosen) {
CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer)
} else {
CardDefaults.cardColors()
}, },
onValueChange = { search = it; catalogExpanded = true },
label = { Text("Tipo de Hallazgo *") },
placeholder = { Text("Buscar o seleccionar") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = catalogExpanded) },
modifier = Modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryEditable),
enabled = !model.busy,
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(onNext = {
catalogExpanded = false
focusManager.moveFocus(FocusDirection.Down)
}),
)
ExposedDropdownMenu(
expanded = catalogExpanded && !model.busy,
onDismissRequest = { catalogExpanded = false; search = "" },
modifier = Modifier.heightIn(max = 320.dp),
) { ) {
Column(Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(3.dp)) { filtered.forEach { item ->
Text(if (chosen) "${item.title}" else item.title, fontWeight = FontWeight.SemiBold) DropdownMenuItem(
Text( text = {
listOfNotNull(item.categoryName, item.code, item.suggestedSeverity?.let { "Gravedad sugerida $it" }) Column {
.joinToString(" · "), Text(item.title, fontWeight = FontWeight.SemiBold)
style = MaterialTheme.typography.bodySmall, item.suggestedSeverity?.let {
Text("Gravedad sugerida: $it/10", style = MaterialTheme.typography.bodySmall)
}
}
},
onClick = {
selectedCatalogId = item.id
other = false
severityText = item.suggestedSeverity?.toString().orEmpty()
catalogExpanded = false
search = ""
keyboard?.hide()
focusManager.moveFocus(FocusDirection.Down)
},
) )
} }
DropdownMenuItem(
text = { Text("Otro / No está en la lista", fontWeight = FontWeight.SemiBold) },
onClick = {
other = true
selectedCatalogId = null
severityText = ""
catalogExpanded = false
search = ""
keyboard?.hide()
focusManager.moveFocus(FocusDirection.Down)
},
)
} }
} }
OutlinedButton(
onClick = {
other = true
selectedCatalogId = null
severityText = ""
},
modifier = Modifier.fillMaxWidth(),
) {
Text(if (other) "✓ OTROS · Hallazgo no catalogado" else "OTROS · No está en el catálogo")
}
if (other) { if (other) {
options.catalog.other.help?.let { Text(it, style = MaterialTheme.typography.bodySmall) } Text("Cargá un nombre claro para identificar el Hallazgo.", style = MaterialTheme.typography.bodySmall)
OutlinedTextField( OutlinedTextField(
value = customTitle, value = customTitle,
onValueChange = { customTitle = it }, onValueChange = { customTitle = it },
label = { Text("Título del nuevo Hallazgo *") }, label = { Text("Nombre del Hallazgo *") },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }),
) )
OutlinedTextField( OutlinedTextField(
value = customLegalBasis, value = customLegalBasis,
onValueChange = { customLegalBasis = it }, onValueChange = { customLegalBasis = it },
label = { Text("Base legal / normativa (opcional)") }, label = { Text("Normativa (opcional)") },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }),
) )
} }
Text("2. Describí lo observado", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) Text("2. Qué observaste", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
selected?.let { selected?.let {
Card(Modifier.fillMaxWidth()) { Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(3.dp)) { Column(Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(3.dp)) {
@@ -318,23 +362,20 @@ fun FieldFindingScreen(model: MainViewModel) {
OutlinedTextField( OutlinedTextField(
value = description, value = description,
onValueChange = { description = it }, onValueChange = { description = it },
label = { Text("Descripción del Hallazgo *") }, label = { Text("Qué observaste *") },
placeholder = { Text("Describí brevemente el problema") },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
minLines = 3, singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }),
) )
OutlinedTextField( OutlinedTextField(
value = severityText, value = severityText,
onValueChange = { value -> severityText = value.filter(Char::isDigit).take(2) }, onValueChange = { value -> severityText = value.filter(Char::isDigit).take(2) },
label = { Text("Gravedad 1 a 10") }, label = { Text("Gravedad (1 a 10, opcional)") },
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true,
)
OutlinedTextField(
value = correctionDueOn,
onValueChange = { correctionDueOn = it },
label = { Text("Fecha de corrección AAAA-MM-DD (opcional)") },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { keyboard?.hide(); focusManager.clearFocus() }),
singleLine = true, singleLine = true,
) )
@@ -348,7 +389,6 @@ fun FieldFindingScreen(model: MainViewModel) {
customLegalBasis = if (other) customLegalBasis else null, customLegalBasis = if (other) customLegalBasis else null,
description = description, description = description,
severity = severity, severity = severity,
correctionDueOn = correctionDueOn,
) )
}, },
enabled = choiceReady && description.isNotBlank() && (severity == null || severity in 1..10) && !model.busy, enabled = choiceReady && description.isNotBlank() && (severity == null || severity in 1..10) && !model.busy,
@@ -63,6 +63,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable 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.focus.FocusDirection
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
@@ -778,6 +779,11 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
enabled = !model.busy, enabled = !model.busy,
modifier = Modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryEditable), modifier = Modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryEditable),
singleLine = true, singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(onNext = {
familyExpanded = false
focusManager.moveFocus(FocusDirection.Down)
}),
) )
ExposedDropdownMenu( ExposedDropdownMenu(
expanded = familyExpanded && !model.busy, expanded = familyExpanded && !model.busy,
@@ -818,9 +824,11 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
value = name, value = name,
onValueChange = { name = it }, onValueChange = { name = it },
label = { Text("Nombre técnico *") }, label = { Text("Nombre técnico *") },
supportingText = { Text("Usá la denominación técnica del elemento. El código DH se genera automáticamente.") }, placeholder = { Text("Denominación técnica") },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true, singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }),
) )
OutlinedTextField( OutlinedTextField(
value = commonName, value = commonName,
@@ -828,17 +836,24 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
label = { Text("Nombre habitual (opcional)") }, label = { Text("Nombre habitual (opcional)") },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true, singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }),
) )
OutlinedTextField( OutlinedTextField(
value = description, value = description,
onValueChange = { description = it }, onValueChange = { description = it },
label = { Text("Descripción (opcional)") }, label = { Text("Descripción (opcional)") },
supportingText = { Text("Dato equivalente al alta desde el panel web.") },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
minLines = 2, singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = if (editableAttributes.isEmpty()) ImeAction.Done else ImeAction.Next),
keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Down) },
onDone = { keyboard?.hide(); focusManager.clearFocus() },
),
) )
editableAttributes.forEach { definition -> editableAttributes.forEachIndexed { index, definition ->
val isLastAttribute = index == editableAttributes.lastIndex
OutlinedTextField( OutlinedTextField(
value = attributeValues[definition.code].orEmpty(), value = attributeValues[definition.code].orEmpty(),
onValueChange = { attributeValues[definition.code] = it }, onValueChange = { attributeValues[definition.code] = it },
@@ -849,8 +864,14 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
}, },
keyboardOptions = KeyboardOptions( keyboardOptions = KeyboardOptions(
keyboardType = if (definition.dataType.uppercase() in setOf("NUMBER", "DECIMAL", "INTEGER", "FLOAT")) KeyboardType.Decimal else KeyboardType.Text, keyboardType = if (definition.dataType.uppercase() in setOf("NUMBER", "DECIMAL", "INTEGER", "FLOAT")) KeyboardType.Decimal else KeyboardType.Text,
imeAction = if (isLastAttribute) ImeAction.Done else ImeAction.Next,
),
keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Down) },
onDone = { keyboard?.hide(); focusManager.clearFocus() },
), ),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true,
) )
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 8.2 KiB

@@ -63,4 +63,14 @@ class FinalFieldFlowContractTest {
assertFalse(acts.contains("Acta SELLADA")) assertFalse(acts.contains("Acta SELLADA"))
assertFalse(acts.contains("Text(\"Email\")")) assertFalse(acts.contains("Text(\"Email\")"))
} }
@Test
fun findingCreationIsCompactSearchableAndHasNoCorrectionDate() {
assertTrue(findings.contains("ExposedDropdownMenuBox"))
assertTrue(findings.contains("Tipo de Hallazgo *"))
assertTrue(findings.contains("Buscar o seleccionar"))
assertTrue(findings.contains("Otro / No está en la lista"))
assertFalse(findings.contains("Fecha de corrección"))
assertFalse(findings.contains("Hallazgos del modelo autoritativo"))
}
} }
@@ -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(33, BuildConfig.VERSION_CODE) assertEquals(35, BuildConfig.VERSION_CODE)
assertEquals("0.19.5-debug", BuildConfig.VERSION_NAME) assertEquals("0.19.7-debug", BuildConfig.VERSION_NAME)
} }
@Test @Test
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "dhv2-api", "name": "dhv2-api",
"version": "0.29.0-4", "version": "0.29.0-5",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "dhv2-api", "name": "dhv2-api",
"version": "0.29.0-4", "version": "0.29.0-5",
"license": "UNLICENSED", "license": "UNLICENSED",
"dependencies": { "dependencies": {
"@nestjs/common": "^11.0.0", "@nestjs/common": "^11.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "dhv2-api", "name": "dhv2-api",
"version": "0.29.0-4", "version": "0.29.0-5",
"private": true, "private": true,
"license": "UNLICENSED", "license": "UNLICENSED",
"scripts": { "scripts": {
@@ -760,6 +760,7 @@ export class AssetsService {
parentId: dto.parentId, parentId: dto.parentId,
operationalAreaId: dto.operationalAreaId, operationalAreaId: dto.operationalAreaId,
operatorCompanyId: dto.operatorCompanyId, operatorCompanyId: dto.operatorCompanyId,
inventoryFamilyId: dto.inventoryFamilyId ?? null,
code: dto.code, code: dto.code,
name: dto.name, name: dto.name,
commonName: dto.commonName ?? null, commonName: dto.commonName ?? null,
@@ -36,6 +36,10 @@ export class CreateFieldDiscoveryDto {
@IsUUID('4') @IsUUID('4')
operatorCompanyId!: string; operatorCompanyId!: string;
@IsOptional()
@IsUUID('4')
inventoryFamilyId?: string | null;
@IsOptional() @IsOptional()
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null) @Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null)
@IsString() @IsString()
@@ -0,0 +1,45 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* F4 cambió el código visible del Acta a ACT-NNNNN-DD-MM-YY, pero el CHECK
* histórico de Hallazgos seguía aceptando sólo ACTA-YYYY-NNNNNN-HNNN.
* El servicio genera el Hallazgo a partir del código real del Acta, por lo que
* PostgreSQL rechazaba altas válidas de campo con un 500.
*/
export class F65FieldFindingCodeContract1790121000000 implements MigrationInterface {
name = 'F65FieldFindingCodeContract1790121000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE inspection_findings
DROP CONSTRAINT IF EXISTS chk_inspection_findings_code
`);
await queryRunner.query(`
ALTER TABLE inspection_findings
ADD CONSTRAINT chk_inspection_findings_code CHECK (
code ~ '^(ACTA-[0-9]{4}-[0-9]{6}|ACT-[0-9]{5}-[0-9]{2}-[0-9]{2}-[0-9]{2})-H[0-9]{3}$'
)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
UPDATE inspection_findings finding
SET code = 'ACTA-' || act.act_year::text || '-' || LPAD(act.act_number::text, 6, '0')
|| '-H' || LPAD(finding.finding_number::text, 3, '0')
FROM inspection_acts act
WHERE finding.act_id = act.id
AND finding.code ~ '^ACT-[0-9]{5}-[0-9]{2}-[0-9]{2}-[0-9]{2}-H[0-9]{3}$'
`);
await queryRunner.query(`
ALTER TABLE inspection_findings
DROP CONSTRAINT IF EXISTS chk_inspection_findings_code
`);
await queryRunner.query(`
ALTER TABLE inspection_findings
ADD CONSTRAINT chk_inspection_findings_code CHECK (
code ~ '^ACTA-[0-9]{4}-[0-9]{6}-H[0-9]{3}$'
)
`);
}
}
@@ -4,7 +4,6 @@ import {
IsOptional, IsOptional,
IsString, IsString,
IsUUID, IsUUID,
Matches,
Max, Max,
MaxLength, MaxLength,
Min, Min,
@@ -56,8 +55,4 @@ export class CreateFieldFindingDto {
@Max(10) @Max(10)
severity?: number; severity?: number;
@IsOptional()
@Transform(optionalText)
@Matches(/^\d{4}-\d{2}-\d{2}$/)
correctionDueOn?: string | null;
} }
@@ -105,7 +105,7 @@ export class FieldFindingsService {
customLegalBasis: dto.customLegalBasis ?? null, customLegalBasis: dto.customLegalBasis ?? null,
description: dto.description, description: dto.description,
severity: dto.severity, severity: dto.severity,
correctionDueOn: dto.correctionDueOn ?? null, correctionDueOn: null,
}; };
const finding = await this.findings.create(act.id, payload, principal, request); const finding = await this.findings.create(act.id, payload, principal, request);
return { return {
@@ -295,6 +295,7 @@ export class FieldInventoryService {
operationalAreaId: context.areaId, operationalAreaId: context.areaId,
// Compatibility-only creation snapshot. Membership never depends on it. // Compatibility-only creation snapshot. Membership never depends on it.
operatorCompanyId: context.companyId, operatorCompanyId: context.companyId,
inventoryFamilyId: dto.familyId ?? null,
description: dto.description ?? null, description: dto.description ?? null,
discoveryNotes: dto.discoveryNotes ?? null, discoveryNotes: dto.discoveryNotes ?? null,
attributes: dto.attributes, attributes: dto.attributes,
+1 -1
View File
@@ -1,2 +1,2 @@
export const API_VERSION = '0.29.0-4'; export const API_VERSION = '0.29.0-5';
export const API_PHASE = 'F6.2'; export const API_PHASE = 'F6.2';
+1 -1
View File
@@ -8,5 +8,5 @@ test('health metadata reports the current F6.1 release', () => {
assert.equal(API_PHASE, 'F6.2'); assert.equal(API_PHASE, 'F6.2');
const pkg = JSON.parse(readFileSync(resolve(process.cwd(), 'package.json'), 'utf8')) as { version: string }; const pkg = JSON.parse(readFileSync(resolve(process.cwd(), 'package.json'), 'utf8')) as { version: string };
assert.equal(API_VERSION, pkg.version); assert.equal(API_VERSION, pkg.version);
assert.equal(API_VERSION, '0.29.0-4'); assert.equal(API_VERSION, '0.29.0-5');
}); });
+17 -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 = 33/); assert.match(gradle, /versionCode = 35/);
assert.match(gradle, /versionName = "0\.19\.5"/); assert.match(gradle, /versionName = "0\.19\.7"/);
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,3 +26,18 @@ test('F5/F6.3 field inventory exposes Other families as reviewable choices to An
assert.match(service, /AS "isOther"/); assert.match(service, /AS "isOther"/);
assert.match(service, /isOtherFamily: family\.isOther/); assert.match(service, /isOtherFamily: family\.isOther/);
}); });
test('field inventory persists the validated Tipo de instalación in the initial asset insert', () => {
const fieldService = readFileSync(
resolve(process.cwd(), 'src/inspection-visits/field-inventory.service.ts'),
'utf8',
);
const assetsService = readFileSync(
resolve(process.cwd(), 'src/asset-master/assets.service.ts'),
'utf8',
);
assert.match(fieldService, /inventoryFamilyId: dto\.familyId \?\? null/);
assert.match(assetsService, /inventoryFamilyId: dto\.inventoryFamilyId \?\? null/);
});
@@ -31,7 +31,8 @@ test('F6.1 uses contextual type catalog for Yacimiento and keeps OTROS/add-anoth
assert.match(catalog, /finding_catalog_asset_overrides override/); assert.match(catalog, /finding_catalog_asset_overrides override/);
assert.match(resolver, /code: 'OTHER'/); assert.match(resolver, /code: 'OTHER'/);
assert.match(resolver, /label: 'OTROS'/); assert.match(resolver, /label: 'OTROS'/);
assert.match(androidFinding, /OTROS · No está en el catálogo/); assert.match(androidFinding, /ExposedDropdownMenuBox/);
assert.match(androidFinding, /Otro \/ No está en la lista/);
assert.match(androidFinding, /También podés registrar otro Hallazgo sobre el mismo Inventario/); assert.match(androidFinding, /También podés registrar otro Hallazgo sobre el mismo Inventario/);
}); });
@@ -0,0 +1,31 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import test from 'node:test';
function source(path: string): string {
return readFileSync(resolve(process.cwd(), path), 'utf8');
}
test('F6.5 finding code CHECK accepts the current visible Acta code', () => {
const migration = source('src/database/migrations/1790121000000-f6-5-field-finding-code-contract.ts');
const service = source('src/inspection-findings/inspection-findings.service.ts');
assert.match(migration, /ACT-\[0-9\]\{5\}-\[0-9\]\{2\}-\[0-9\]\{2\}-\[0-9\]\{2\}/);
assert.match(migration, /-H\[0-9\]\{3\}/);
assert.match(service, /code: `\$\{act\.code\}-H\$\{String\(findingNumber\)\.padStart\(3, '0'\)\}`/);
});
test('F6.5 mobile finding creation never asks the inspector for a correction date', () => {
const dto = source('src/inspection-visits/dto/create-field-finding.dto.ts');
const adapter = source('src/inspection-visits/field-findings.service.ts');
const androidRequest = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/FieldFindingsMobile.kt');
const androidScreen = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/FieldFindingScreen.kt');
assert.doesNotMatch(dto, /correctionDueOn/);
assert.match(adapter, /correctionDueOn: null/);
assert.doesNotMatch(androidRequest.match(/data class CreateFieldFindingRequest\([\s\S]*?\n\)/)?.[0] ?? '', /correctionDueOn/);
assert.doesNotMatch(androidScreen, /Fecha de corrección/);
assert.match(androidScreen, /ExposedDropdownMenuBox/);
assert.match(androidScreen, /Tipo de Hallazgo \*/);
});
+1
View File
@@ -3,6 +3,7 @@ WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN npm ci RUN npm ci
COPY tsconfig*.json vite.config.ts index.html ./ COPY tsconfig*.json vite.config.ts index.html ./
COPY public ./public
COPY src ./src COPY src ./src
RUN npm run build RUN npm run build
+1
View File
@@ -4,6 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="referrer" content="strict-origin-when-cross-origin" /> <meta name="referrer" content="strict-origin-when-cross-origin" />
<link rel="icon" type="image/webp" href="/favicon.webp" />
<title>DH Inspección V2</title> <title>DH Inspección V2</title>
</head> </head>
<body> <body>
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB