Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2455a89c3e | ||
|
|
2a99183d31 | ||
|
|
675d01bea7 | ||
|
|
26d79f1e4d | ||
|
|
496bb785c1 | ||
|
|
f513817388 | ||
|
|
92a21d8f15 | ||
|
|
753fa9915f | ||
|
|
ec0ef732f7 | ||
|
|
87adf71114 | ||
|
|
0a856a1857 | ||
|
|
247c652d40 | ||
|
|
b5ee17ce95 | ||
|
|
cca2d38b98 | ||
|
|
ebce2bb957 | ||
|
|
963485618b | ||
|
|
8f051158c1 | ||
|
|
2d8425dec5 | ||
|
|
cfae2bb9ac | ||
|
|
5d8949d7d2 | ||
|
|
304adcd9b0 | ||
|
|
20dd110928 |
@@ -0,0 +1,15 @@
|
||||
# Checklist final Android 0.19.0
|
||||
|
||||
La candidata sólo se promueve si el mismo SHA cumple:
|
||||
|
||||
- iniciar Inspección;
|
||||
- crear/abrir Acta;
|
||||
- seleccionar o crear Instalación/Subinstalación;
|
||||
- exigir GPS + fotografía para Inventario nacido en campo antes de Hallazgos;
|
||||
- registrar Hallazgos sobre el elemento seleccionado;
|
||||
- cerrar contenido del Acta y mostrar **Pendiente de firma**;
|
||||
- completar firma del Inspector y manifestación/firma o negativa de la empresa;
|
||||
- mostrar el Acta como **Firmada y cerrada**;
|
||||
- permitir cerrar la Inspección sólo con todas las Actas activas firmadas y cerradas;
|
||||
- no exponer estados internos en inglés en la interfaz de campo;
|
||||
- Android CI / RC, DH V2 CI y auditoría de dependencias en verde.
|
||||
+55
-42
@@ -1,8 +1,8 @@
|
||||
# DH Inspección Android · release de campo 0.19.0
|
||||
# DH Inspección Android · release final de campo 0.19.0
|
||||
|
||||
## Candidata vigente
|
||||
|
||||
- Fase funcional: **Campo final / Inspección → Acta → Inventario → Hallazgos → Firma**.
|
||||
- Fase funcional: **Flujo final de campo · Inspección → Acta → Hallazgos → Firma**.
|
||||
- `versionName`: **0.19.0**.
|
||||
- `versionCode`: **28**.
|
||||
- Application ID release: `com.korexlabs.dhinspeccion`.
|
||||
@@ -11,48 +11,61 @@
|
||||
|
||||
La variante debug es independiente de la app productiva y puede instalarse para QA/presentación sin sobrescribir una instalación release histórica.
|
||||
|
||||
## Procedimiento de campo validado
|
||||
## Procedimiento operativo validado
|
||||
|
||||
1. La inspección se **planifica y asigna desde el tablero web**. La APK no crea inspecciones nuevas.
|
||||
2. En la tablet el inspector abre una inspección asignada y, al llegar al lugar, pulsa **Iniciar inspección**.
|
||||
3. Con la inspección en curso se crea o selecciona el **Acta** de trabajo. Sólo puede existir un Acta en borrador a la vez; una inspección puede contener varias Actas sucesivas.
|
||||
4. Los **Hallazgos** se registran exclusivamente sobre **Instalaciones o Subinstalaciones**.
|
||||
5. Si el elemento ya existe, se busca y selecciona desde Inventario de campo.
|
||||
6. Si no existe, se da de alta desde la APK siguiendo la misma estructura que el tablero:
|
||||
- Instalación dentro del Yacimiento de la inspección;
|
||||
- Subinstalación dentro de una Instalación seleccionada;
|
||||
- nombre técnico / identificación obligatorio;
|
||||
- Código DH de campo autogenerado por el servidor;
|
||||
- nombre habitual opcional;
|
||||
- descripción y observaciones de alta opcionales;
|
||||
- clasificación técnica obligatoria cuando el tipo la requiera;
|
||||
- atributos técnicos dinámicos obligatorios según la configuración del tipo;
|
||||
- Área y Operadora heredadas de la inspección planificada;
|
||||
- ubicación del dispositivo y fotografía obligatorias antes de habilitar Hallazgos.
|
||||
7. Sobre el elemento se selecciona un Hallazgo del catálogo contextual o **OTROS** cuando corresponda; se registra descripción, gravedad y evidencia fotográfica si aplica.
|
||||
8. Al terminar el contenido se pulsa **Cerrar Acta y dejar pendiente de firma**. El contenido y los Hallazgos quedan inmutables y el estado visible pasa a **Pendiente de firma**.
|
||||
9. Se registran las firmas y la manifestación de la empresa. La ausencia por sí sola no modifica el contenido cerrado: el Acta permanece pendiente hasta resolver el circuito de firma/manifestación conforme a la regla vigente.
|
||||
10. Una vez resuelta la firma, el Acta queda **Firmada e inmutable**. La inspección sólo puede cerrarse cuando todas sus Actas no canceladas estén finalizadas.
|
||||
La APK 0.19.0 fija como recorrido principal de campo:
|
||||
|
||||
## Castellano y presentación
|
||||
1. **Iniciar Inspección**. Al iniciarla se habilitan Actas, Hallazgos e Inventario de campo.
|
||||
2. **Crear o abrir un Acta**. Puede haber varias Actas dentro de una misma Inspección, pero sólo una puede permanecer en elaboración al mismo tiempo.
|
||||
3. **Agregar Hallazgos** sobre una Instalación o Subinstalación seleccionada.
|
||||
4. Si el elemento ya existe, se lo selecciona desde el Inventario del Área/Yacimiento de la Inspección.
|
||||
5. Si no existe, se da de alta desde campo sin abandonar el Acta.
|
||||
6. Un elemento nuevo debe completar **GPS + fotografía** antes de poder recibir Hallazgos.
|
||||
7. Al terminar el contenido del Acta se usa **Cerrar Acta y dejar pendiente de firma**. Desde ese momento el contenido queda inmutable y el estado visible es **Pendiente de firma**.
|
||||
8. La firma del Inspector y la manifestación/firma o negativa de la empresa completan el Acta, que pasa a **Firmada y cerrada**.
|
||||
9. La Inspección sólo puede cerrarse cuando todas sus Actas no canceladas están firmadas y cerradas.
|
||||
|
||||
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.
|
||||
La nomenclatura técnica interna de API (`DRAFT`, `LOCKED`, `SEALED`, etc.) no se muestra al inspector: la interfaz usa textos operativos en castellano.
|
||||
|
||||
También se incorporó la marca oficial de Mendoza provista para esta release en las pantallas de acceso y desbloqueo biométrico.
|
||||
## Alta de Instalaciones y Subinstalaciones
|
||||
|
||||
## Integridad del alta de Inventario
|
||||
El alta móvil queda alineada con el Dashboard y con las validaciones del servidor.
|
||||
|
||||
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.
|
||||
### Instalación
|
||||
|
||||
El servidor continúa siendo la autoridad para:
|
||||
- contexto territorial: Yacimiento/Área de la Inspección;
|
||||
- clasificación técnica compatible;
|
||||
- **Nombre técnico** obligatorio;
|
||||
- Nombre habitual opcional;
|
||||
- Descripción opcional;
|
||||
- atributos técnicos definidos por el tipo; sólo los marcados como obligatorios bloquean el guardado;
|
||||
- código DH generado por el sistema cuando corresponde;
|
||||
- GPS de la tablet;
|
||||
- fotografía obligatoria antes de crear Hallazgos.
|
||||
|
||||
- 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.
|
||||
### Subinstalación
|
||||
|
||||
Además de lo anterior requiere elegir primero la **Instalación padre**. El buscador consulta mientras se escribe y cada resultado es seleccionable tocando la tarjeta.
|
||||
|
||||
La clasificación y los atributos se obtienen dinámicamente desde el mismo catálogo administrado por el Dashboard; la APK no mantiene listas técnicas paralelas.
|
||||
|
||||
## Actas y firma
|
||||
|
||||
La terminología visible se simplifica:
|
||||
|
||||
- `DRAFT` → **En elaboración**;
|
||||
- `LOCKED` → **Pendiente de firma**;
|
||||
- `SEALED` → **Firmada y cerrada**.
|
||||
|
||||
Cerrar el contenido no cierra automáticamente la Inspección y tampoco obliga a terminar la firma en ese instante: puede continuarse con otra Acta. Una vez cerrado el contenido, el Acta es inmutable.
|
||||
|
||||
Después de la firma/cierre definitivo se mantiene el circuito documental existente: PDF del Acta e informe INF editable (DOCX) para oficina.
|
||||
|
||||
## Castellano e identidad visual
|
||||
|
||||
Se revisaron las pantallas activas de campo para evitar exponer estados y opciones internas en inglés. Entre otros cambios: Pasaporte/Otro en documentos, estados de Inspección/Acta/Hallazgo en castellano, Correo electrónico, Huella de integridad y terminología de cierre orientada al usuario.
|
||||
|
||||
La pantalla de ingreso y el desbloqueo biométrico utilizan la identidad visual de Mendoza ya incorporada al proyecto.
|
||||
|
||||
## Barrera obligatoria
|
||||
|
||||
@@ -60,16 +73,16 @@ Todo cambio Android o de API que pueda afectar al cliente móvil debe pasar `And
|
||||
|
||||
1. validación de identidad, HTTPS y políticas básicas del manifest;
|
||||
2. Android lint;
|
||||
3. unit tests Android reales, incluyendo contratos del procedimiento final;
|
||||
3. pruebas unitarias Android reales, con verificación de que exista al menos una prueba ejecutada;
|
||||
4. `assembleDebug`;
|
||||
5. `assembleRelease` para comprobar que la variante productiva compile;
|
||||
6. empaquetado del APK debug con SHA-256 y metadata de commit/versionado.
|
||||
|
||||
Además, `DH V2 CI` debe mantener verdes API, WEB y contrato Docker/migraciones antes de incorporar la release a `main`.
|
||||
Además, `DH V2 CI` debe mantener verdes API, WEB y contrato Docker/migraciones antes de promover cambios que afecten al servidor.
|
||||
|
||||
## Firma release
|
||||
|
||||
La clave histórica de firma **no se versiona ni se reemplaza**. La CI compila la variante release para detectar roturas, pero la APK productiva final debe firmarse con la clave histórica antes de instalarse como actualización de `com.korexlabs.dhinspeccion`.
|
||||
La clave histórica de firma **no se versiona ni se reemplaza**. La CI compila la variante release para detectar roturas, pero una APK productiva que deba actualizar `com.korexlabs.dhinspeccion` tiene que firmarse con la clave histórica.
|
||||
|
||||
No se debe crear una clave nueva para resolver una falta de acceso: eso rompería la continuidad de actualización de tablets que ya tengan una versión firmada con la clave anterior.
|
||||
|
||||
@@ -78,7 +91,7 @@ No se debe crear una clave nueva para resolver una falta de acceso: eso romperí
|
||||
Antes de distribuir una APK productiva:
|
||||
|
||||
- todas las barreras de CI del SHA exacto deben estar verdes;
|
||||
- comprobar certificado/huella de firma contra la versión histórica;
|
||||
- realizar actualización sobre al menos una tablet con la versión productiva anterior;
|
||||
- ejecutar smoke funcional contra producción: ingreso, abrir inspección asignada, iniciar inspección, crear Acta, buscar Instalación/Subinstalación, alta de campo, ubicación/foto, Hallazgo, cerrar Acta a pendiente de firma, firma/finalización y cierre de inspección;
|
||||
- comprobar certificado/huella de firma contra la versión histórica si se distribuirá como actualización productiva;
|
||||
- realizar actualización sobre al menos una tablet con la versión productiva anterior cuando corresponda;
|
||||
- ejecutar smoke funcional: ingreso, iniciar Inspección, crear Acta, seleccionar/crear Instalación o Subinstalación, GPS/foto, Hallazgo, cerrar Acta → Pendiente de firma, firma/manifestación, cierre de Acta y cierre de Inspección;
|
||||
- registrar el SHA Git y SHA-256 de la APK distribuida.
|
||||
|
||||
@@ -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. Cerrala antes de crear la siguiente."
|
||||
error = "Ya existe un Acta en elaboración. Cerrala o cancelala 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,16 +237,15 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
familyId: String?,
|
||||
name: String,
|
||||
commonName: String?,
|
||||
description: String?,
|
||||
discoveryNotes: String?,
|
||||
attributes: Map<String, Any?>,
|
||||
latitude: Double,
|
||||
longitude: Double,
|
||||
accuracyM: Double?,
|
||||
description: String? = null,
|
||||
) {
|
||||
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) {
|
||||
@@ -261,7 +260,6 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
name = name.trim(),
|
||||
commonName = commonName?.trim()?.takeIf { it.isNotBlank() },
|
||||
description = description?.trim()?.takeIf { it.isNotBlank() },
|
||||
discoveryNotes = discoveryNotes?.trim()?.takeIf { it.isNotBlank() },
|
||||
attributes = attributes,
|
||||
deviceLatitude = latitude,
|
||||
deviceLongitude = longitude,
|
||||
@@ -269,7 +267,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
deviceCapturedAt = Instant.now().toString(),
|
||||
)
|
||||
selectedFieldAsset = repository.createFieldAsset(visitId, request)
|
||||
notice = "Inventario creado con ubicación. Falta la fotografía obligatoria. Si ya existía, podés fusionarlo antes de continuar."
|
||||
notice = "Inventario creado con GPS. Falta la fotografía obligatoria. Si ya existía, podés fusionarlo antes de continuar."
|
||||
reloadCurrentInventory(visitId)
|
||||
}
|
||||
}
|
||||
@@ -323,7 +321,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
)
|
||||
selectedFieldAsset = selectedFieldAsset?.copy(capture = response.capture)
|
||||
notice = if (response.capture.readyForFinding) {
|
||||
"Captura completa: ubicación y fotografía registradas."
|
||||
"Captura completa: GPS y fotografía registrados."
|
||||
} else {
|
||||
"Fotografía registrada."
|
||||
}
|
||||
@@ -344,7 +342,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
val assetId = selectedFieldAsset?.asset?.id ?: return
|
||||
val draft = selectedDraftAct()
|
||||
if (draft == null) {
|
||||
error = "Creá o seleccioná el Acta en borrador antes de registrar Hallazgos."
|
||||
error = "Creá o seleccioná el Acta en elaboración antes de registrar Hallazgos."
|
||||
return
|
||||
}
|
||||
launchBusy {
|
||||
@@ -366,7 +364,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
val assetId = selectedFieldAsset?.asset?.id ?: return
|
||||
val actId = selectedDraftAct()?.id
|
||||
if (actId == null) {
|
||||
error = "No hay un Acta en borrador seleccionada."
|
||||
error = "No hay un Acta en elaboración seleccionada."
|
||||
return
|
||||
}
|
||||
if (description.isBlank()) {
|
||||
@@ -423,7 +421,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
description = description,
|
||||
)
|
||||
loadEvidenceInternal(findingId)
|
||||
notice = "Evidencia fotográfica registrada con ubicación."
|
||||
notice = "Evidencia fotográfica registrada con GPS."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -475,7 +473,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
absenceReason = reason.trim(),
|
||||
),
|
||||
)
|
||||
notice = "Ausencia del responsable registrada. Al cerrar el Acta, dejá asentada la ausencia en el circuito de firma."
|
||||
notice = "Ausencia del responsable registrada. La manifestación de empresa quedará pendiente y deberá resolverse antes de firmar y cerrar el Acta."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -484,12 +482,12 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
launchBusy {
|
||||
actClosure = actsRepository.lock(actId)
|
||||
refreshSelectedActInternal(actId)
|
||||
notice = "Acta cerrada y pendiente de firma. El contenido y los Hallazgos quedaron inmutables."
|
||||
notice = "Acta cerrada y pendiente de firma. Su contenido quedó inmutable; las firmas y manifestaciones pueden completarse a continuación."
|
||||
}
|
||||
}
|
||||
|
||||
fun reopenSelectedAct() {
|
||||
error = "Un Acta cerrada y pendiente de firma es inmutable y no puede volver a borrador."
|
||||
error = "Un Acta pendiente de firma es inmutable y no puede volver a edición."
|
||||
}
|
||||
|
||||
fun signSelectedActAsInspector(
|
||||
@@ -528,8 +526,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
fun recordCompanyOutcome(status: String, reason: String) {
|
||||
val actId = selectedAct?.id ?: return
|
||||
if (status !in setOf("REFUSED", "ABSENT")) {
|
||||
error = "La manifestación de la empresa debe resolverse con firma, negativa o ausencia documentada."
|
||||
if (status != "REFUSED") {
|
||||
error = "La ausencia no resuelve la manifestación de la empresa. Registrá una firma o una negativa."
|
||||
return
|
||||
}
|
||||
if (reason.trim().length < 10) {
|
||||
@@ -538,11 +536,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
launchBusy {
|
||||
actClosure = actsRepository.companyOutcome(actId, status, reason)
|
||||
notice = if (status == "ABSENT") {
|
||||
"Ausencia para la firma de empresa asentada."
|
||||
} else {
|
||||
"Negativa a firmar asentada."
|
||||
}
|
||||
notice = "Negativa a firmar asentada."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -554,7 +548,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
refreshSelectedActInternal(actId)
|
||||
loadActsInternal(currentVisit.id, selectDraft = false)
|
||||
clearFindingState()
|
||||
notice = "${selectedAct?.code ?: "Acta"} firmada, finalizada e inmutable. Podés crear otra Acta o continuar hacia el cierre de la inspección."
|
||||
notice = "${selectedAct?.code ?: "Acta"} firmada y cerrada. Podés crear otra Acta o continuar hacia el cierre de la Inspección."
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -185,7 +185,7 @@ fun FieldFindingScreen(model: MainViewModel) {
|
||||
Text(asset.name, fontWeight = FontWeight.Bold)
|
||||
Text("${asset.code} · Acta ${options.act.code}", style = MaterialTheme.typography.bodySmall)
|
||||
Text(
|
||||
"Ubicación y foto del Inventario: ${if (options.capture.readyForFinding) "completas" else "pendientes"}",
|
||||
"GPS + foto del Inventario: ${if (options.capture.readyForFinding) "completo" else "pendiente"}",
|
||||
color = if (options.capture.readyForFinding) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
@@ -210,10 +210,7 @@ fun FieldFindingScreen(model: MainViewModel) {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text("${finding.code} · ${finding.title}", fontWeight = FontWeight.SemiBold)
|
||||
Text(
|
||||
"Gravedad: ${finding.severity ?: "sin definir"} · ${findingStatusLabel(finding.status)}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
Text("Gravedad: ${finding.severity ?: "s/d"} · ${findingStatusLabelEs(finding.status)}", style = MaterialTheme.typography.bodySmall)
|
||||
Text(
|
||||
"Evidencias: ${evidence.size} · Fotos: ${evidence.count { it.kind == "PHOTO" }}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
@@ -229,7 +226,7 @@ fun FieldFindingScreen(model: MainViewModel) {
|
||||
enabled = !model.busy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Tomar foto con ubicación")
|
||||
Text("Tomar foto con GPS")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -369,7 +366,7 @@ fun FieldFindingScreen(model: MainViewModel) {
|
||||
enabled = !model.busy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Tomar foto con ubicación")
|
||||
Text("Tomar foto con GPS")
|
||||
}
|
||||
Text("También podés registrar otro Hallazgo sobre el mismo Inventario.", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
@@ -378,17 +375,6 @@ fun FieldFindingScreen(model: MainViewModel) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun findingStatusLabel(status: String): String = when (status) {
|
||||
"OPEN" -> "Abierto"
|
||||
"COMPANY_RESPONDED" -> "Respondido por la empresa"
|
||||
"UNDER_REVIEW" -> "En revisión"
|
||||
"VERIFICATION_PENDING" -> "Verificación pendiente"
|
||||
"READY_TO_CLOSE" -> "Listo para cierre"
|
||||
"CLOSED" -> "Cerrado"
|
||||
"CANCELLED" -> "Cancelado"
|
||||
else -> "Estado no reconocido"
|
||||
}
|
||||
|
||||
private fun findingHasPermission(context: Context, permission: String): Boolean =
|
||||
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
|
||||
|
||||
@@ -407,7 +393,7 @@ private suspend fun currentFindingGeo(context: Context): FindingGeoSnapshot = su
|
||||
client.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token)
|
||||
.addOnSuccessListener { location ->
|
||||
if (!continuation.isActive) return@addOnSuccessListener
|
||||
if (location == null) continuation.resumeWithException(IllegalStateException("No se pudo obtener una ubicación actual."))
|
||||
if (location == null) continuation.resumeWithException(IllegalStateException("No se pudo obtener una ubicación GPS actual."))
|
||||
else continuation.resume(FindingGeoSnapshot(location.latitude, location.longitude, location.accuracy.toDouble()))
|
||||
}
|
||||
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
|
||||
|
||||
-32
@@ -1,32 +0,0 @@
|
||||
package com.korexlabs.dhinspeccion.ui
|
||||
|
||||
import com.korexlabs.dhinspeccion.MainViewModel
|
||||
import com.korexlabs.dhinspeccion.data.FieldType
|
||||
|
||||
/**
|
||||
* Compatibilidad de compilación para pantallas heredadas que aún forman parte del árbol Android,
|
||||
* aunque la navegación productiva use ModernVisitRoot.
|
||||
*/
|
||||
internal fun MainViewModel.createFieldAsset(
|
||||
type: FieldType,
|
||||
parentId: String?,
|
||||
familyId: String?,
|
||||
name: String,
|
||||
commonName: String?,
|
||||
attributes: Map<String, Any?>,
|
||||
latitude: Double,
|
||||
longitude: Double,
|
||||
accuracyM: Double?,
|
||||
) = createFieldAsset(
|
||||
type = type,
|
||||
parentId = parentId,
|
||||
familyId = familyId,
|
||||
name = name,
|
||||
commonName = commonName,
|
||||
description = null,
|
||||
discoveryNotes = null,
|
||||
attributes = attributes,
|
||||
latitude = latitude,
|
||||
longitude = longitude,
|
||||
accuracyM = accuracyM,
|
||||
)
|
||||
@@ -9,8 +9,8 @@ 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.height
|
||||
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
|
||||
@@ -31,6 +31,7 @@ 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.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
@@ -102,23 +103,6 @@ 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("") }
|
||||
@@ -126,8 +110,15 @@ private fun EnhancedLoginScreen(model: MainViewModel, onPasswordLogin: () -> Uni
|
||||
var passwordVisible by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
|
||||
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
MendozaBrand()
|
||||
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(14.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.ic_mendoza_launcher_exact),
|
||||
contentDescription = "Gobierno de Mendoza",
|
||||
modifier = Modifier.fillMaxWidth().height(170.dp),
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
Text("DH Inspección", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
|
||||
Text("Aplicación de campo · Dirección de Hidrocarburos")
|
||||
model.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
model.notice?.let { Text(it, color = MaterialTheme.colorScheme.primary) }
|
||||
OutlinedTextField(
|
||||
@@ -219,9 +210,10 @@ private fun BiometricUnlockScreen(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.logo_mendoza_oficial),
|
||||
painter = painterResource(R.drawable.ic_mendoza_launcher_exact),
|
||||
contentDescription = "Gobierno de Mendoza",
|
||||
modifier = Modifier.size(120.dp),
|
||||
modifier = Modifier.fillMaxWidth().height(140.dp),
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
Text("DH Inspección", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
|
||||
Text("Ingresá con tu huella")
|
||||
|
||||
@@ -11,30 +11,79 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Assignment
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.korexlabs.dhinspeccion.MainViewModel
|
||||
import com.korexlabs.dhinspeccion.data.DhRepository
|
||||
import com.korexlabs.dhinspeccion.data.MobileInspectionOpenRepository
|
||||
import com.korexlabs.dhinspeccion.data.MobilePlanningAsset
|
||||
import com.korexlabs.dhinspeccion.data.VisitSummary
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun MobileHomeScreen(model: MainViewModel) {
|
||||
val session = model.session ?: return
|
||||
val fieldVisits = model.visits.filter { it.status != "DRAFT" }
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val openRepository = remember(context) { MobileInspectionOpenRepository(context) }
|
||||
|
||||
var showOpen by rememberSaveable { mutableStateOf(false) }
|
||||
var areas by remember { mutableStateOf<List<MobilePlanningAsset>>(emptyList()) }
|
||||
var operators by remember { mutableStateOf<List<MobilePlanningAsset>>(emptyList()) }
|
||||
var selectedAreaId by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var selectedCompanyId by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var loadingContext by remember { mutableStateOf(false) }
|
||||
var opening by remember { mutableStateOf(false) }
|
||||
var localError by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
fun loadAreas() {
|
||||
scope.launch {
|
||||
loadingContext = true
|
||||
localError = null
|
||||
runCatching { openRepository.areas().data }
|
||||
.onSuccess { areas = it }
|
||||
.onFailure { localError = DhRepository.humanError(it) }
|
||||
loadingContext = false
|
||||
}
|
||||
}
|
||||
|
||||
fun chooseArea(areaId: String) {
|
||||
selectedAreaId = areaId
|
||||
selectedCompanyId = null
|
||||
operators = emptyList()
|
||||
scope.launch {
|
||||
loadingContext = true
|
||||
localError = null
|
||||
runCatching { openRepository.operators(areaId).data }
|
||||
.onSuccess { operators = it }
|
||||
.onFailure { localError = DhRepository.humanError(it) }
|
||||
loadingContext = false
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(showOpen) {
|
||||
if (showOpen && areas.isEmpty()) loadAreas()
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxSize().padding(top = 28.dp)) {
|
||||
Row(
|
||||
@@ -42,18 +91,16 @@ fun MobileHomeScreen(model: MainViewModel) {
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Column {
|
||||
Text("Mis inspecciones", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
|
||||
Text(session.displayName, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
Row {
|
||||
OutlinedButton(onClick = { model.loadVisits() }, enabled = !model.busy) {
|
||||
Icon(Icons.Filled.Refresh, contentDescription = null)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
OutlinedButton(onClick = { model.loadVisits() }, enabled = !model.busy && !opening) {
|
||||
Text("Actualizar")
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
OutlinedButton(onClick = { model.logout() }) { Text("Salir") }
|
||||
OutlinedButton(onClick = { model.logout() }, enabled = !opening) { Text("Salir") }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,49 +110,97 @@ fun MobileHomeScreen(model: MainViewModel) {
|
||||
) {
|
||||
model.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
model.notice?.let { Text(it, color = MaterialTheme.colorScheme.primary) }
|
||||
localError?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
|
||||
Surface(
|
||||
Button(
|
||||
onClick = {
|
||||
showOpen = !showOpen
|
||||
localError = null
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
enabled = !opening,
|
||||
) {
|
||||
Row(
|
||||
Modifier.padding(14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Icon(Icons.Filled.Assignment, contentDescription = null, tint = MaterialTheme.colorScheme.secondary)
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text("Trabajo planificado desde oficina", fontWeight = FontWeight.Bold)
|
||||
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)
|
||||
Text(
|
||||
"Acá aparecen las inspecciones ya planificadas y asignadas desde el tablero. La visita se inicia únicamente al llegar al lugar.",
|
||||
"Elegí el Área y la Operadora vigente. La inspección se crea autoasignada a vos y queda iniciada con la fecha y hora del servidor.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
|
||||
Text("1. Área", fontWeight = FontWeight.SemiBold)
|
||||
if (loadingContext && areas.isEmpty()) CircularProgressIndicator()
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(areas, key = { it.id }) { area ->
|
||||
AssistChip(
|
||||
onClick = { chooseArea(area.id) },
|
||||
label = { Text(if (area.id == selectedAreaId) "✓ ${area.name}" else area.name) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedAreaId != null) {
|
||||
Text("2. Operadora", fontWeight = FontWeight.SemiBold)
|
||||
if (loadingContext && operators.isEmpty()) {
|
||||
CircularProgressIndicator()
|
||||
} else if (operators.isEmpty()) {
|
||||
Text("No hay una Operadora vigente para el Área seleccionada.", color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(operators, key = { it.id }) { company ->
|
||||
AssistChip(
|
||||
onClick = { selectedCompanyId = company.id },
|
||||
label = { Text(if (company.id == selectedCompanyId) "✓ ${company.name}" else company.name) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
val areaId = selectedAreaId ?: return@Button
|
||||
val companyId = selectedCompanyId ?: return@Button
|
||||
scope.launch {
|
||||
opening = true
|
||||
localError = null
|
||||
runCatching { openRepository.open(areaId, companyId) }
|
||||
.onSuccess { opened ->
|
||||
showOpen = false
|
||||
model.openVisit(opened.id)
|
||||
}
|
||||
.onFailure { localError = DhRepository.humanError(it) }
|
||||
opening = false
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = selectedAreaId != null && selectedCompanyId != null && !opening && !loadingContext,
|
||||
) {
|
||||
Text(if (opening) "Abriendo…" else "Abrir inspección ahora")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (model.busy && fieldVisits.isEmpty()) {
|
||||
if (model.busy && model.visits.isEmpty()) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
|
||||
} else if (fieldVisits.isEmpty()) {
|
||||
Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Icon(Icons.Filled.Assignment, contentDescription = null, tint = MaterialTheme.colorScheme.secondary)
|
||||
Text("No tenés inspecciones listas para campo", fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
"Cuando una inspección quede planificada y asignada desde el tablero, aparecerá en esta pantalla.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Button(onClick = { model.loadVisits() }, enabled = !model.busy) { Text("Actualizar lista") }
|
||||
}
|
||||
} else if (model.visits.isEmpty()) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("No tenés inspecciones asignadas. Podés abrir una nueva desde esta tablet.")
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
items(fieldVisits, key = { it.id }) { visit ->
|
||||
items(model.visits, key = { it.id }) { visit ->
|
||||
MobileVisitCard(visit) { model.openVisit(visit.id) }
|
||||
}
|
||||
item { Spacer(Modifier.height(24.dp)) }
|
||||
@@ -116,33 +211,21 @@ fun MobileHomeScreen(model: MainViewModel) {
|
||||
|
||||
@Composable
|
||||
private fun MobileVisitCard(visit: VisitSummary, onOpen: () -> Unit) {
|
||||
ElevatedCard(onClick = onOpen, modifier = Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(15.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Card(onClick = onOpen, modifier = Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(visit.code, fontWeight = FontWeight.Bold)
|
||||
StatusPill(mobileVisitStatusLabel(visit.status))
|
||||
Text(visitStatusLabelEs(visit.status))
|
||||
}
|
||||
Text(visit.scopeAsset?.name ?: visit.operationalArea?.name ?: "Alcance sin informar", fontWeight = FontWeight.SemiBold)
|
||||
Text(visit.operatorCompany?.name ?: "Operadora sin definir")
|
||||
visit.operationalArea?.name?.takeIf { it != visit.scopeAsset?.name }?.let {
|
||||
Text("Área · $it", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
Text(visit.operationalArea?.name ?: "Área sin definir", style = MaterialTheme.typography.bodySmall)
|
||||
visit.plannedStartAt?.let {
|
||||
Text("Planificada · ${it.replace('T', ' ').take(16)}", style = MaterialTheme.typography.bodySmall)
|
||||
Text("Prevista: ${it.replace('T', ' ').take(16)}", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
Text(
|
||||
"Inventario previsto: ${visit.assetCount} · Equipo inspector: ${visit.memberCount}",
|
||||
"Inventario: ${visit.assetCount} · Equipo inspector: ${visit.memberCount}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun mobileVisitStatusLabel(status: String): String = when (status) {
|
||||
"DRAFT" -> "Borrador"
|
||||
"PLANNED" -> "Planificada"
|
||||
"IN_PROGRESS" -> "En curso"
|
||||
"CLOSED" -> "Cerrada"
|
||||
"CANCELLED" -> "Cancelada"
|
||||
else -> "Estado no reconocido"
|
||||
}
|
||||
|
||||
+25
-56
@@ -245,7 +245,7 @@ fun ModernMobileActsScreen(
|
||||
) {
|
||||
Text(
|
||||
if (newActUrgency == "URGENT") {
|
||||
"El plazo urgente se computará conforme a la política institucional vigente al cerrar el contenido del Acta."
|
||||
"El plazo urgente se computará desde el Acta conforme a la política institucional vigente al cerrar su contenido."
|
||||
} else {
|
||||
"El plazo no urgente comienza con la oficialización GEDO, no al crear el Acta."
|
||||
},
|
||||
@@ -319,11 +319,7 @@ 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(documentTypeLabel(kind)) },
|
||||
)
|
||||
FilterChip(selected = documentType == kind, onClick = { documentType = kind }, label = { Text(documentTypeLabelEs(kind)) })
|
||||
}
|
||||
}
|
||||
OutlinedTextField(documentNumber, { documentNumber = it }, label = { Text("Documento *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
|
||||
@@ -360,7 +356,7 @@ fun ModernMobileActsScreen(
|
||||
Text("Cerrar Acta", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
Text(
|
||||
"Al cerrar el Acta, su contenido y sus Hallazgos quedan inmutables. El Acta quedará pendiente de firma.",
|
||||
"Al cerrar el contenido, el Acta queda inmutable y pasa a Pendiente de firma. Podés firmarla ahora o continuar con otra Acta.",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Button(
|
||||
@@ -368,13 +364,6 @@ fun ModernMobileActsScreen(
|
||||
enabled = !model.busy && closure?.responsible != null,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Cerrar Acta y dejar pendiente de firma") }
|
||||
if (closure?.responsible == null) {
|
||||
Text(
|
||||
"Antes de cerrar, registrá al responsable de la empresa o su ausencia.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -383,7 +372,7 @@ 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 in setOf("SIGNED", "REFUSED", "ABSENT")
|
||||
val companyResolved = companyOutcome?.status == "SIGNED" || companyOutcome?.status == "REFUSED"
|
||||
|
||||
ElevatedCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
@@ -392,8 +381,8 @@ fun ModernMobileActsScreen(
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Acta pendiente de firma", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
Text("El contenido ya es inmutable. Completá las firmas o dejá asentada la ausencia/negativa correspondiente para finalizarla.")
|
||||
closure?.closure?.preparedSha256?.let { Text("SHA-256 de cierre · $it", style = MaterialTheme.typography.bodySmall) }
|
||||
Text("El contenido ya está cerrado e inmutable. Completá la firma del inspector y la manifestación de la empresa cuando corresponda.")
|
||||
closure?.closure?.preparedSha256?.let { Text("Huella de integridad · $it", style = MaterialTheme.typography.bodySmall) }
|
||||
|
||||
HorizontalDivider()
|
||||
Text("Firma del inspector", fontWeight = FontWeight.Bold)
|
||||
@@ -413,29 +402,23 @@ 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 registrada"
|
||||
"ABSENT" -> "Ausencia para la firma registrada"
|
||||
else -> "Manifestación registrada"
|
||||
"REFUSED" -> "Negativa a firmar"
|
||||
else -> humanBackendLabelEs(companyOutcome.status)
|
||||
}
|
||||
SuccessLine(detail)
|
||||
companyOutcome.reason?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
|
||||
companyOutcome.companyStatement?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
|
||||
} else if (closure?.responsible?.attendanceStatus == "ABSENT") {
|
||||
val savedAbsenceReason = closure.responsible.absenceReason.orEmpty()
|
||||
Surface(Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.secondaryContainer) {
|
||||
Surface(Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.errorContainer) {
|
||||
Row(Modifier.padding(12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Icon(Icons.Filled.ErrorOutline, null, tint = MaterialTheme.colorScheme.secondary)
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text("El responsable fue registrado como ausente.", fontWeight = FontWeight.SemiBold)
|
||||
if (savedAbsenceReason.isNotBlank()) Text(savedAbsenceReason, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
Icon(Icons.Filled.ErrorOutline, null, tint = MaterialTheme.colorScheme.error)
|
||||
Text(
|
||||
"La ausencia no resuelve la manifestación. Debe registrarse firma o negativa antes de firmar y cerrar el Acta.",
|
||||
Modifier.weight(1f),
|
||||
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)) {
|
||||
@@ -479,11 +462,11 @@ fun ModernMobileActsScreen(
|
||||
) {
|
||||
Icon(Icons.Filled.CheckCircle, null)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Finalizar Acta")
|
||||
Text("Firmar y cerrar Acta")
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
"La inspección no puede cerrarse hasta resolver la firma, negativa o ausencia de esta Acta.",
|
||||
"La Inspección no puede cerrarse hasta completar las manifestaciones de esta Acta.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -497,9 +480,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 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) }
|
||||
Text("Acta firmada y cerrada", fontWeight = FontWeight.Bold)
|
||||
Text("Desde este cierre se genera el PDF del Acta y el informe INF editable (DOCX) para el Inspector.")
|
||||
closure?.closure?.finalSha256?.let { Text("SHA-256 · $it", style = MaterialTheme.typography.bodySmall) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -518,12 +501,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 finalizadas")
|
||||
SuccessLine("Todas las Actas están firmadas y cerradas")
|
||||
} else {
|
||||
Text(
|
||||
"Actas pendientes de firma o finalización: ${pendingActs.joinToString { it.code }}",
|
||||
"Falta firmar y cerrar: ${pendingActs.joinToString { it.code }}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
@@ -532,7 +515,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") }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -548,21 +531,7 @@ 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" -> "Pendiente de firma"
|
||||
"SEALED" -> "Finalizada"
|
||||
"CANCELLED" -> "Cancelada"
|
||||
else -> "Estado no reconocido"
|
||||
}
|
||||
private fun modernActStatusLabel(status: String): String = actStatusLabelEs(status)
|
||||
|
||||
private fun hasModernActLocation(context: Context): Boolean =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
|
||||
|
||||
@@ -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 y controles", fontWeight = FontWeight.Bold)
|
||||
Text("Antecedentes", fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
"${checklist.antecedents} antecedentes · ${checklist.upcomingControls} próximos controles",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
@@ -297,7 +297,6 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
|
||||
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("") }
|
||||
@@ -314,7 +313,6 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
|
||||
name = ""
|
||||
commonName = ""
|
||||
description = ""
|
||||
discoveryNotes = ""
|
||||
selectedTypeId = null
|
||||
selectedFamilyId = null
|
||||
familySearch = ""
|
||||
@@ -431,7 +429,7 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
|
||||
pendingPhotoGeo = geo
|
||||
takePicture.launch(uri)
|
||||
}
|
||||
.onFailure { localError = it.message ?: "No se pudo obtener la ubicación para la fotografía." }
|
||||
.onFailure { localError = it.message ?: "No se pudo obtener el GPS para la fotografía." }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -449,14 +447,13 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
|
||||
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 actual." }
|
||||
.onFailure { localError = it.message ?: "No se pudo capturar la ubicación GPS." }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -806,15 +803,15 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
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.") },
|
||||
label = { Text("Nombre técnico *") },
|
||||
supportingText = { Text("Usá la denominación técnica del elemento. El código DH se genera automáticamente.") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = commonName,
|
||||
onValueChange = { commonName = it },
|
||||
label = { Text("Nombre habitual") },
|
||||
label = { Text("Nombre habitual (opcional)") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
)
|
||||
@@ -822,14 +819,7 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
|
||||
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.") },
|
||||
supportingText = { Text("Dato equivalente al alta desde el panel web.") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
minLines = 2,
|
||||
)
|
||||
@@ -851,12 +841,12 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
ModernStepHeader(if (isSubinstallation) 3 else 2, "Ubicación y foto")
|
||||
ModernStepHeader(if (isSubinstallation) 3 else 2, "GPS + 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 la ubicación y abrimos la cámara. La foto es obligatoria antes de registrar Hallazgos.",
|
||||
"Al guardar capturamos el GPS y abrimos la cámara. La foto es obligatoria antes de registrar Hallazgos.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
@@ -948,7 +938,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 ubicación / foto",
|
||||
if (item.readyForFinding) "Listo para Hallazgos" else "Falta GPS / foto",
|
||||
color = if (item.readyForFinding) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
@@ -987,7 +977,7 @@ private fun ModernCaptureCard(
|
||||
IconButton(onClick = onClose) { Icon(Icons.Filled.Close, "Cerrar") }
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(if (gps) "✓ Ubicación" else "Ubicación pendiente", color = if (gps) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error)
|
||||
Text(if (gps) "✓ GPS" else "GPS pendiente", color = if (gps) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error)
|
||||
Text("Fotos · $photos")
|
||||
}
|
||||
if (!ready) {
|
||||
@@ -1087,20 +1077,14 @@ private fun ModernStepHeader(step: Int, title: String) {
|
||||
|
||||
private fun modernItemTypeCode(item: FieldInventoryItem): String {
|
||||
val type = item.type ?: return ""
|
||||
return modernNormalize(type.typeName ?: type.name)
|
||||
return modernNormalize(type.code.ifBlank { type.typeName ?: type.name })
|
||||
}
|
||||
|
||||
private fun modernNormalize(value: String): String = value.trim().lowercase()
|
||||
.replace('ó', 'o').replace('í', 'i').replace('á', 'a').replace('é', 'e').replace('ú', 'u')
|
||||
.replace("_", "").replace("-", "").replace(" ", "")
|
||||
|
||||
private fun modernStatusLabel(status: String): String = when (status) {
|
||||
"PLANNED" -> "Planificada"
|
||||
"IN_PROGRESS" -> "En curso"
|
||||
"CLOSED" -> "Cerrada"
|
||||
"CANCELLED" -> "Cancelada"
|
||||
else -> "Estado no reconocido"
|
||||
}
|
||||
private fun modernStatusLabel(status: String): String = visitStatusLabelEs(status)
|
||||
|
||||
private fun buildModernAttributes(type: FieldType, values: Map<String, String>): Map<String, Any?> =
|
||||
type.attributes.mapNotNull { definition ->
|
||||
@@ -1133,7 +1117,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 actual."))
|
||||
if (location == null) continuation.resumeWithException(IllegalStateException("No se pudo obtener una ubicación GPS actual."))
|
||||
else continuation.resume(ModernGeoSnapshot(location.latitude, location.longitude, location.accuracy.toDouble()))
|
||||
}
|
||||
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
|
||||
|
||||
@@ -49,7 +49,7 @@ fun SignaturePad(
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(label, fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold)
|
||||
Text(
|
||||
"Firmá dentro del recuadro. La imagen se guarda como PNG y se incorpora al hash del Acta.",
|
||||
"Firmá dentro del recuadro. La imagen se guarda como PNG y se incorpora a la huella de integridad del Acta.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
Canvas(
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.korexlabs.dhinspeccion.ui
|
||||
|
||||
import java.util.Locale
|
||||
|
||||
internal fun visitStatusLabelEs(status: String?): String = when (status?.uppercase(Locale.ROOT)) {
|
||||
"PLANNED" -> "Planificada"
|
||||
"IN_PROGRESS" -> "En curso"
|
||||
"CLOSED" -> "Cerrada"
|
||||
"CANCELLED" -> "Cancelada"
|
||||
null, "" -> "Sin estado"
|
||||
else -> humanBackendLabelEs(status)
|
||||
}
|
||||
|
||||
internal fun actStatusLabelEs(status: String?): String = when (status?.uppercase(Locale.ROOT)) {
|
||||
"DRAFT" -> "En elaboración"
|
||||
"LOCKED" -> "Pendiente de firma"
|
||||
"SEALED" -> "Firmada y cerrada"
|
||||
"CLOSED" -> "Cerrada"
|
||||
"RECTIFIED" -> "Rectificada"
|
||||
"CANCELLED" -> "Cancelada"
|
||||
null, "" -> "Sin estado"
|
||||
else -> humanBackendLabelEs(status)
|
||||
}
|
||||
|
||||
internal fun findingStatusLabelEs(status: String?): String = when (status?.uppercase(Locale.ROOT)) {
|
||||
"OPEN" -> "Abierto"
|
||||
"PENDING" -> "Pendiente"
|
||||
"COMPANY_RESPONSE_PENDING" -> "Pendiente de respuesta de la empresa"
|
||||
"COMPANY_RESPONDED" -> "Respondido por la empresa"
|
||||
"VERIFICATION_PENDING" -> "Pendiente de verificación"
|
||||
"VERIFICATION_SCHEDULED" -> "Verificación programada"
|
||||
"READY_TO_CLOSE" -> "Listo para cierre"
|
||||
"CLOSED" -> "Cerrado"
|
||||
"CANCELLED" -> "Cancelado"
|
||||
null, "" -> "Sin estado"
|
||||
else -> humanBackendLabelEs(status)
|
||||
}
|
||||
|
||||
internal fun documentTypeLabelEs(type: String): String = when (type.uppercase(Locale.ROOT)) {
|
||||
"DNI" -> "DNI"
|
||||
"CUIL" -> "CUIL"
|
||||
"PASSPORT" -> "Pasaporte"
|
||||
"OTHER" -> "Otro"
|
||||
else -> humanBackendLabelEs(type)
|
||||
}
|
||||
|
||||
internal fun humanBackendLabelEs(value: String): String {
|
||||
val clean = value.trim().replace('_', ' ').replace('-', ' ').lowercase(Locale("es", "AR"))
|
||||
return clean.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale("es", "AR")) else it.toString() }
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 6.6 KiB |
+31
-42
@@ -6,63 +6,52 @@ 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()
|
||||
private val home = File("src/main/java/com/korexlabs/dhinspeccion/ui/MobileHomeScreen.kt").readText()
|
||||
private val labels = File("src/main/java/com/korexlabs/dhinspeccion/ui/UiSpanishLabels.kt").readText()
|
||||
|
||||
@Test
|
||||
fun 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\""))
|
||||
fun inspectionActFindingFlowMatchesFieldProcedure() {
|
||||
assertTrue(visit.contains("Iniciar inspección"))
|
||||
assertTrue(acts.contains("Crear nueva Acta"))
|
||||
assertTrue(acts.contains("Agregar Hallazgo"))
|
||||
assertTrue(visit.contains("Instalación"))
|
||||
assertTrue(visit.contains("Subinstalación"))
|
||||
assertTrue(visit.contains("Guardar y tomar foto"))
|
||||
assertTrue(acts.contains("Cerrar Acta y dejar pendiente de firma"))
|
||||
assertTrue(labels.contains("\"LOCKED\" -> \"Pendiente de firma\""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun 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"))
|
||||
fun fieldCreateKeepsDashboardEquivalentMinimumAndCaptureBarrier() {
|
||||
assertTrue(visit.contains("Nombre técnico *"))
|
||||
assertTrue(visit.contains("Nombre habitual (opcional)"))
|
||||
assertTrue(visit.contains("Descripción (opcional)"))
|
||||
assertTrue(visit.contains("familyReady"))
|
||||
assertTrue(visit.contains("attributesReady"))
|
||||
assertTrue(visit.contains("GPS + foto"))
|
||||
assertTrue(visit.contains("La foto es obligatoria antes de registrar Hallazgos"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun 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()"))
|
||||
fun structuralSelectionUsesTheServerTypeCode() {
|
||||
assertTrue(visit.contains("type.code.ifBlank { type.typeName ?: type.name }"))
|
||||
assertTrue(visit.contains("modernItemTypeCode(it) in setOf(\"instalacion\", \"subinstalacion\")"))
|
||||
}
|
||||
|
||||
@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)"))
|
||||
fun currentFieldUiDoesNotExposeBackendEnglishStates() {
|
||||
assertTrue(home.contains("visitStatusLabelEs(visit.status)"))
|
||||
assertTrue(findings.contains("findingStatusLabelEs(finding.status)"))
|
||||
assertTrue(acts.contains("documentTypeLabelEs(kind)"))
|
||||
assertTrue(labels.contains("\"PASSPORT\" -> \"Pasaporte\""))
|
||||
assertTrue(labels.contains("\"OTHER\" -> \"Otro\""))
|
||||
assertFalse(acts.contains("Finalizar y BLOQUEAR Acta"))
|
||||
assertFalse(acts.contains("Acta BLOQUEADA"))
|
||||
assertFalse(acts.contains("SELLAR Acta"))
|
||||
assertFalse(acts.contains("Acta SELLADA"))
|
||||
assertFalse(acts.contains("Text(\"Email\")"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ function mountedRepoFile(path: string): string {
|
||||
return readFileSync(resolve(process.cwd(), '..', path), 'utf8');
|
||||
}
|
||||
|
||||
test('Android 0.19.0 targets production API and keeps a distinct installable debug identity', () => {
|
||||
test('F6.3 Android test cut targets production API and has a distinct installable debug version', () => {
|
||||
const gradle = mountedRepoFile('android-app/app/build.gradle.kts');
|
||||
|
||||
assert.match(gradle, /versionCode = 28/);
|
||||
|
||||
@@ -35,7 +35,7 @@ test('F6.1 uses contextual type catalog for Yacimiento and keeps OTROS/add-anoth
|
||||
assert.match(androidFinding, /También podés registrar otro Hallazgo sobre el mismo Inventario/);
|
||||
});
|
||||
|
||||
test('legacy mobile-open endpoints remain available without becoming the active Android workflow', () => {
|
||||
test('F6.1 exposes mobile planning context and opens an inspection self-assigned to the current inspector', () => {
|
||||
const controller = source('src/inspection-visits/inspection-visits.controller.ts');
|
||||
const dto = source('src/inspection-visits/dto/open-mobile-inspection.dto.ts');
|
||||
|
||||
@@ -45,21 +45,23 @@ test('legacy mobile-open endpoints remain available without becoming the active
|
||||
assert.match(controller, /@RequirePermissions\('inspections\.execute'\)/);
|
||||
assert.match(controller, /assertMobileInspector\(principal\)/);
|
||||
assert.match(controller, /leadInspectorUserId: principal\.userId/);
|
||||
assert.match(controller, /plannedStartAt: new Date\(\)\.toISOString\(\)/);
|
||||
assert.match(controller, /this\.lifecycle\.plan\(created\.id/);
|
||||
assert.match(controller, /this\.lifecycle\.start\(created\.id/);
|
||||
assert.match(dto, /operationalAreaId!: string/);
|
||||
assert.match(dto, /operatorCompanyId!: string/);
|
||||
});
|
||||
|
||||
test('Android lists inspections assigned from office and starts them only from the visit screen', () => {
|
||||
test('F6.1 Android lets the inspector choose Area and current Operator and open the inspection now', () => {
|
||||
const client = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/MobileInspectionOpen.kt');
|
||||
const home = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/MobileHomeScreen.kt');
|
||||
const visit = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernVisitRoot.kt');
|
||||
const gate = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/LoginGate.kt');
|
||||
|
||||
assert.match(home, /Trabajo planificado desde oficina/);
|
||||
assert.match(home, /inspecciones asignadas desde el tablero/);
|
||||
assert.doesNotMatch(home, /Abrir inspección ahora/);
|
||||
assert.doesNotMatch(home, /Operadora vigente/);
|
||||
assert.match(home, /model\.openVisit\(visit\.id\)/);
|
||||
assert.match(visit, /Iniciar inspección/);
|
||||
assert.match(visit, /model\.startVisit\(\)/);
|
||||
assert.match(client, /inspection-visits\/mobile\/planning-context\/areas/);
|
||||
assert.match(client, /inspection-visits\/mobile\/open/);
|
||||
assert.match(home, /Abrir inspección/);
|
||||
assert.match(home, /Abrir inspección ahora/);
|
||||
assert.match(home, /Operadora vigente/);
|
||||
assert.match(home, /model\.openVisit\(opened\.id\)/);
|
||||
assert.match(gate, /else -> MobileHomeScreen\(model\)/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user