Merge pull request #48 from enlineawork/fix/android-actas-picker-modern-ui
fix(android): Actas estables, selector de Instalación y UI moderna 0.18.0
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
# F6.4 · Android Campo Moderno
|
||||
|
||||
Criterios de aceptación:
|
||||
|
||||
- el buscador de Instalación padre consulta mientras se escribe y cada resultado se puede seleccionar directamente;
|
||||
- la selección de Instalación abre la clasificación compatible de Subinstalación;
|
||||
- Actas utiliza un read-model móvil desacoplado del módulo documental de oficina;
|
||||
- la APK conserva GPS + foto antes de habilitar Hallazgos sobre Inventario nacido en campo;
|
||||
- el lifecycle Acta DRAFT → LOCKED → SEALED no cambia;
|
||||
- la interfaz de campo usa el tema DH moderno y mantiene acceso claro a Actas, Inventario y Hallazgos;
|
||||
- Android lint/tests/debug/release, API tests/build, WEB build y Docker contract deben quedar verdes.
|
||||
+26
-20
@@ -1,28 +1,40 @@
|
||||
# DH Inspección Android · release Campo Dinámico 0.17.0
|
||||
# DH Inspección Android · release Campo Moderno 0.18.0
|
||||
|
||||
## Candidata vigente
|
||||
|
||||
- Fase funcional: **Campo Dinámico / alta rápida de Inventario**.
|
||||
- `versionName`: **0.17.0**.
|
||||
- `versionCode`: **26**.
|
||||
- Fase funcional: **Campo Moderno / UX dinámica de Inventario y Actas**.
|
||||
- `versionName`: **0.18.0**.
|
||||
- `versionCode`: **27**.
|
||||
- Application ID release: `com.korexlabs.dhinspeccion`.
|
||||
- Application ID debug/QA: `com.korexlabs.dhinspeccion.debug`.
|
||||
- API: `https://dhv2.korexlabs.com/api/v3/`.
|
||||
- Launcher de presentación: adaptación vectorial local inspirada en el escudo institucional publicado por Gobierno de Mendoza en `mendoza.gov.ar`, para reemplazar el ícono genérico del sistema.
|
||||
|
||||
La variante debug es independiente de la app productiva y puede instalarse para QA/presentación sin sobrescribir una instalación release histórica.
|
||||
|
||||
## Novedades de campo 0.17.0
|
||||
## Novedades de campo 0.18.0
|
||||
|
||||
La pantalla de una Inspección incorpora un acceso destacado a **Modo campo rápido**. La carga de una Subinstalación se resuelve como flujo guiado:
|
||||
La APK adopta una capa visual Material 3 propia de DH: superficies más limpias, jerarquía visual más clara, botones y estados más legibles, tarjetas con menor ruido y una navegación consistente entre Inspección, Actas e Inventario.
|
||||
|
||||
1. elegir la **Instalación padre** dentro del Yacimiento de la Inspección;
|
||||
2. buscar y seleccionar la **clasificación técnica** compatible, identificar el elemento y completar únicamente los datos dinámicos que correspondan;
|
||||
3. tocar **Guardar y tomar foto**: la APK captura GPS y abre la cámara automáticamente.
|
||||
La carga de una Subinstalación mantiene la integridad del modelo físico pero reduce pasos:
|
||||
|
||||
Se mantienen las reglas de integridad existentes: la fotografía sigue siendo obligatoria antes de habilitar Hallazgos, el alta queda ligada al contexto de la Inspección, se conserva la posibilidad de buscar Inventario existente y el mecanismo de fusión de duplicados continúa disponible para altas de campo.
|
||||
1. el buscador de **Instalaciones padre** ahora consulta mientras el inspector escribe;
|
||||
2. cada coincidencia aparece como tarjeta seleccionable y tocarla avanza directamente a la identificación de la Subinstalación;
|
||||
3. la clasificación técnica continúa filtrada por compatibilidad con la Instalación elegida;
|
||||
4. **Guardar y tomar foto** captura GPS y abre la cámara automáticamente.
|
||||
|
||||
La búsqueda de clasificación técnica está preparada para catálogos extensos de Subinstalaciones sin obligar al inspector a recorrer una fila horizontal completa.
|
||||
Se conserva la regla de que un elemento nacido en campo no puede recibir Hallazgos hasta contar con GPS y fotografía.
|
||||
|
||||
## Actas en Android
|
||||
|
||||
El espacio de Actas fue rediseñado para mostrar con claridad:
|
||||
|
||||
- contexto de la Inspección y Yacimiento;
|
||||
- estado de cada Acta y cantidad de Hallazgos;
|
||||
- urgencia mediante selección explícita;
|
||||
- responsable de empresa, bloqueo, firmas, manifestación y sellado;
|
||||
- condición necesaria para cerrar la Inspección.
|
||||
|
||||
El listado que consume Android usa un read-model móvil liviano (`/inspection-visits/:visitId/acts/mobile`). Esta lectura no depende del módulo documental de oficina ni de `inspection_reports`, de modo que entrar a una Inspección en campo no queda acoplado a la proyección de Informes/GEDO.
|
||||
|
||||
## Barrera obligatoria
|
||||
|
||||
@@ -35,13 +47,7 @@ Todo cambio Android o de API que pueda afectar al cliente móvil debe pasar `And
|
||||
5. `assembleRelease` para comprobar que la variante productiva compile;
|
||||
6. empaquetado del APK debug con SHA-256 y metadata de commit/versionado.
|
||||
|
||||
La barrera de lint exige además manejo explícito de la revocación de permisos de ubicación durante una captura GPS y declara la cámara como capacidad de hardware opcional, sin relajar permisos ni desactivar reglas globalmente.
|
||||
|
||||
El artefacto de CI contiene:
|
||||
|
||||
- APK debug;
|
||||
- archivo `.sha256`;
|
||||
- `release-metadata.txt` con fase, versión, versionCode, commit, applicationId, API base y canal.
|
||||
Además, `DH V2 CI` debe mantener verdes API, WEB y contrato Docker/migraciones antes de promover el cambio.
|
||||
|
||||
## Firma release
|
||||
|
||||
@@ -56,5 +62,5 @@ Antes de distribuir una APK productiva:
|
||||
- todas las barreras de CI del SHA exacto deben estar verdes;
|
||||
- comprobar certificado/huella de firma contra la versión histórica;
|
||||
- realizar actualización sobre al menos una tablet con la versión productiva anterior;
|
||||
- ejecutar smoke funcional contra el entorno objetivo: login, lista de inspecciones, inicio, alta rápida de Subinstalación, GPS/foto, búsqueda de Inventario existente, Acta, Hallazgo y cierre;
|
||||
- ejecutar smoke funcional contra el entorno objetivo: login, abrir Inspección, abrir Actas sin error, crear Acta, búsqueda dinámica de Instalación, selección de padre, alta de Subinstalación, GPS/foto, Hallazgo y cierre;
|
||||
- registrar el SHA Git y SHA-256 de la APK distribuida.
|
||||
|
||||
@@ -12,8 +12,8 @@ android {
|
||||
applicationId = "com.korexlabs.dhinspeccion"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 26
|
||||
versionName = "0.17.0"
|
||||
versionCode = 27
|
||||
versionName = "0.18.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables.useSupportLibrary = true
|
||||
|
||||
@@ -207,7 +207,7 @@ data class MobileCloseVisitRequest(
|
||||
)
|
||||
|
||||
private interface MobileActsApi {
|
||||
@GET("inspection-visits/{visitId}/acts")
|
||||
@GET("inspection-visits/{visitId}/acts/mobile")
|
||||
suspend fun listActs(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Path("visitId") visitId: String,
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.korexlabs.dhinspeccion.ui
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
private val DhLightColors = lightColorScheme(
|
||||
primary = Color(0xFF075E63),
|
||||
onPrimary = Color(0xFFFFFFFF),
|
||||
primaryContainer = Color(0xFFD6F0F0),
|
||||
onPrimaryContainer = Color(0xFF073F43),
|
||||
secondary = Color(0xFF445E74),
|
||||
onSecondary = Color(0xFFFFFFFF),
|
||||
secondaryContainer = Color(0xFFDCE7F1),
|
||||
onSecondaryContainer = Color(0xFF243F54),
|
||||
tertiary = Color(0xFF6A5A42),
|
||||
background = Color(0xFFF6F8FA),
|
||||
onBackground = Color(0xFF182025),
|
||||
surface = Color(0xFFFFFFFF),
|
||||
onSurface = Color(0xFF182025),
|
||||
surfaceVariant = Color(0xFFEDF1F4),
|
||||
onSurfaceVariant = Color(0xFF455158),
|
||||
outline = Color(0xFF77848B),
|
||||
outlineVariant = Color(0xFFD7DEE2),
|
||||
error = Color(0xFFB3261E),
|
||||
onError = Color(0xFFFFFFFF),
|
||||
errorContainer = Color(0xFFF9DEDC),
|
||||
onErrorContainer = Color(0xFF6F1713),
|
||||
)
|
||||
|
||||
private val DhDarkColors = darkColorScheme(
|
||||
primary = Color(0xFF88D4D7),
|
||||
onPrimary = Color(0xFF00373A),
|
||||
primaryContainer = Color(0xFF074F53),
|
||||
onPrimaryContainer = Color(0xFFB7EBED),
|
||||
secondary = Color(0xFFB6CBE0),
|
||||
onSecondary = Color(0xFF203646),
|
||||
secondaryContainer = Color(0xFF344C60),
|
||||
onSecondaryContainer = Color(0xFFDCE7F1),
|
||||
background = Color(0xFF101416),
|
||||
onBackground = Color(0xFFE0E4E6),
|
||||
surface = Color(0xFF171C1F),
|
||||
onSurface = Color(0xFFE0E4E6),
|
||||
surfaceVariant = Color(0xFF263035),
|
||||
onSurfaceVariant = Color(0xFFC3CDD2),
|
||||
outline = Color(0xFF8C989E),
|
||||
error = Color(0xFFFFB4AB),
|
||||
onError = Color(0xFF690005),
|
||||
errorContainer = Color(0xFF93000A),
|
||||
onErrorContainer = Color(0xFFFFDAD6),
|
||||
)
|
||||
|
||||
private val DhShapes = androidx.compose.material3.Shapes(
|
||||
extraSmall = RoundedCornerShape(8.dp),
|
||||
small = RoundedCornerShape(12.dp),
|
||||
medium = RoundedCornerShape(16.dp),
|
||||
large = RoundedCornerShape(22.dp),
|
||||
extraLarge = RoundedCornerShape(28.dp),
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun DhTheme(content: @Composable () -> Unit) {
|
||||
MaterialTheme(
|
||||
colorScheme = if (isSystemInDarkTheme()) DhDarkColors else DhLightColors,
|
||||
shapes = DhShapes,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
@@ -74,7 +74,7 @@ fun DhRoot(model: MainViewModel, activity: FragmentActivity) {
|
||||
if (model.session == null) unlocked = false
|
||||
}
|
||||
|
||||
MaterialTheme {
|
||||
DhTheme {
|
||||
Surface(Modifier.fillMaxSize()) {
|
||||
when {
|
||||
model.session == null -> EnhancedLoginScreen(model) {
|
||||
@@ -91,7 +91,7 @@ fun DhRoot(model: MainViewModel, activity: FragmentActivity) {
|
||||
},
|
||||
)
|
||||
model.fieldFindingOptions != null -> FieldFindingScreen(model)
|
||||
model.visit != null -> DynamicVisitRoot(model)
|
||||
model.visit != null -> ModernVisitRoot(model)
|
||||
else -> MobileHomeScreen(model)
|
||||
}
|
||||
}
|
||||
|
||||
+565
@@ -0,0 +1,565 @@
|
||||
package com.korexlabs.dhinspeccion.ui
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Assignment
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.EditNote
|
||||
import androidx.compose.material.icons.filled.ErrorOutline
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.google.android.gms.location.LocationServices
|
||||
import com.google.android.gms.location.Priority
|
||||
import com.google.android.gms.tasks.CancellationTokenSource
|
||||
import com.korexlabs.dhinspeccion.MainViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import java.io.File
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
||||
private data class ModernActSignatureGeo(
|
||||
val latitude: Double,
|
||||
val longitude: Double,
|
||||
val accuracyM: Double?,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun ModernMobileActsScreen(
|
||||
model: MainViewModel,
|
||||
onBack: () -> Unit,
|
||||
onGoInventory: () -> Unit,
|
||||
) {
|
||||
val visit = model.visit ?: return
|
||||
val selected = model.selectedAct
|
||||
val closure = model.actClosure
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var newActUrgency by rememberSaveable(visit.id) { mutableStateOf("NON_URGENT") }
|
||||
var attendance by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.attendanceStatus ?: "PRESENT") }
|
||||
var fullName by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.fullName.orEmpty()) }
|
||||
var documentType by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.documentType ?: "DNI") }
|
||||
var documentNumber by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.documentNumber.orEmpty()) }
|
||||
var position by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.position.orEmpty()) }
|
||||
var email by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.email.orEmpty()) }
|
||||
var phone by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.phone.orEmpty()) }
|
||||
var absenceReason by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.absenceReason.orEmpty()) }
|
||||
var refusalReason by rememberSaveable(selected?.id) { mutableStateOf("") }
|
||||
var manifestation by rememberSaveable(selected?.id) { mutableStateOf("CONFORMITY") }
|
||||
var dissentStatement by rememberSaveable(selected?.id) { mutableStateOf("") }
|
||||
|
||||
LaunchedEffect(visit.id) { model.reloadActs() }
|
||||
|
||||
fun signWithGeo(file: File, company: Boolean) {
|
||||
scope.launch {
|
||||
val geo = runCatching { currentModernActSignatureGeo(context) }.getOrNull()
|
||||
if (company) {
|
||||
model.signSelectedActAsCompany(
|
||||
png = file,
|
||||
latitude = geo?.latitude,
|
||||
longitude = geo?.longitude,
|
||||
accuracyM = geo?.accuracyM,
|
||||
manifestation = manifestation,
|
||||
statement = dissentStatement.takeIf { manifestation == "DISSENT" },
|
||||
)
|
||||
} else {
|
||||
model.signSelectedActAsInspector(
|
||||
png = file,
|
||||
latitude = geo?.latitude,
|
||||
longitude = geo?.longitude,
|
||||
accuracyM = geo?.accuracyM,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 18.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
ModernHeader(title = "Actas", subtitle = visit.code, onBack = onBack)
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = MaterialTheme.shapes.large,
|
||||
tonalElevation = 1.dp,
|
||||
) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||
Text(
|
||||
visit.scopeAsset?.name ?: "Yacimiento",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
visit.operatorCompany?.name?.takeIf { it.isNotBlank() }?.let {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ModernMessage(model)
|
||||
|
||||
if (model.acts.isEmpty()) {
|
||||
ElevatedCard(Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
Modifier.padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Icon(Icons.Filled.Assignment, contentDescription = null, tint = MaterialTheme.colorScheme.secondary)
|
||||
Text("Todavía no hay Actas", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
"Creá la primera Acta y después incorporá Hallazgos seleccionando el Inventario.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text("Actas de esta inspección", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
model.acts.forEach { act ->
|
||||
val active = selected?.id == act.id
|
||||
Surface(
|
||||
onClick = { model.selectAct(act.id) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = if (active) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface,
|
||||
tonalElevation = if (active) 1.dp else 0.dp,
|
||||
border = BorderStroke(
|
||||
if (active) 2.dp else 1.dp,
|
||||
if (active) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant,
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
Modifier.padding(15.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Icon(
|
||||
when (act.status) {
|
||||
"LOCKED" -> Icons.Filled.Lock
|
||||
"SEALED" -> Icons.Filled.CheckCircle
|
||||
else -> Icons.Filled.EditNote
|
||||
},
|
||||
contentDescription = null,
|
||||
tint = if (active) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.secondary,
|
||||
)
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
Text(act.code, fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
"${modernActStatusLabel(act.status)} · ${if (act.urgency == "URGENT") "Urgente" else "No urgente"}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
"${act.findingCount} Hallazgo${if (act.findingCount == 1) "" else "s"} · ${act.assetCount} elemento${if (act.assetCount == 1) "" else "s"}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
if (active) Icon(Icons.Filled.CheckCircle, "Seleccionada", tint = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val hasDraftAct = model.acts.any { it.status == "DRAFT" }
|
||||
if (visit.status == "IN_PROGRESS" && !hasDraftAct) {
|
||||
ElevatedCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Surface(shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.primaryContainer) {
|
||||
Icon(Icons.Filled.Add, null, Modifier.padding(10.dp), tint = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Column {
|
||||
Text("Nueva Acta", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
Text("Un solo borrador activo por vez", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
|
||||
Text("Urgencia", fontWeight = FontWeight.SemiBold)
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
FilterChip(
|
||||
selected = newActUrgency == "NON_URGENT",
|
||||
onClick = { newActUrgency = "NON_URGENT" },
|
||||
label = { Text("No urgente") },
|
||||
)
|
||||
FilterChip(
|
||||
selected = newActUrgency == "URGENT",
|
||||
onClick = { newActUrgency = "URGENT" },
|
||||
label = { Text("Urgente") },
|
||||
)
|
||||
}
|
||||
Surface(
|
||||
Modifier.fillMaxWidth(),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
) {
|
||||
Text(
|
||||
if (newActUrgency == "URGENT") {
|
||||
"El plazo urgente se computará desde el Acta conforme a la política institucional vigente al bloquearla."
|
||||
} else {
|
||||
"El plazo no urgente comienza con la oficialización GEDO, no al crear el Acta."
|
||||
},
|
||||
Modifier.padding(12.dp),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
Button(
|
||||
onClick = { model.createAct(newActUrgency) },
|
||||
enabled = !model.busy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Icon(Icons.Filled.Add, null)
|
||||
Spacer(Modifier.width(7.dp))
|
||||
Text(if (model.busy) "Creando…" else "Crear nueva Acta")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (selected != null) {
|
||||
HorizontalDivider()
|
||||
Surface(
|
||||
Modifier.fillMaxWidth(),
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(selected.code, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
StatusPill(modernActStatusLabel(selected.status))
|
||||
}
|
||||
Text(if (selected.urgency == "URGENT") "Urgente" else "No urgente", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text("${selected.findingCount} Hallazgos · ${selected.assetCount} elementos de Inventario", style = MaterialTheme.typography.bodySmall)
|
||||
selected.deadlineAt?.let { Text("Vencimiento · $it", style = MaterialTheme.typography.bodySmall) }
|
||||
if (selected.deadlineAt == null && selected.deadlineBasis == "GEDO_DATE") {
|
||||
Text("Vencimiento pendiente de fecha GEDO", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
when (selected.status) {
|
||||
"DRAFT" -> {
|
||||
ElevatedCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text("Hallazgos", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
"Elegí una Instalación o Subinstalación existente, o creala en campo si todavía no está registrada.",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Button(onClick = onGoInventory, modifier = Modifier.fillMaxWidth()) {
|
||||
Icon(Icons.Filled.Add, null)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Agregar Hallazgo")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ElevatedCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Filled.Person, null, tint = MaterialTheme.colorScheme.primary)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Responsable de la empresa", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
FilterChip(selected = attendance == "PRESENT", onClick = { attendance = "PRESENT" }, label = { Text("Presente") })
|
||||
FilterChip(selected = attendance == "ABSENT", onClick = { attendance = "ABSENT" }, label = { Text("Ausente") })
|
||||
}
|
||||
if (attendance == "PRESENT") {
|
||||
OutlinedTextField(fullName, { fullName = it }, label = { Text("Nombre y apellido *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
listOf("DNI", "CUIL", "PASSPORT", "OTHER").forEach { kind ->
|
||||
FilterChip(selected = documentType == kind, onClick = { documentType = kind }, label = { Text(kind) })
|
||||
}
|
||||
}
|
||||
OutlinedTextField(documentNumber, { documentNumber = it }, label = { Text("Documento *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
|
||||
OutlinedTextField(position, { position = it }, label = { Text("Cargo *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
|
||||
OutlinedTextField(email, { email = it }, label = { Text("Email") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
|
||||
OutlinedTextField(phone, { phone = it }, label = { Text("Teléfono") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
|
||||
Button(
|
||||
onClick = { model.setCompanyResponsiblePresent(fullName, documentType, documentNumber, position, email, phone) },
|
||||
enabled = !model.busy && fullName.isNotBlank() && documentNumber.isNotBlank() && position.isNotBlank(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Guardar responsable") }
|
||||
} else {
|
||||
OutlinedTextField(
|
||||
absenceReason,
|
||||
{ absenceReason = it },
|
||||
label = { Text("Motivo de ausencia *") },
|
||||
minLines = 2,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Button(
|
||||
onClick = { model.setCompanyResponsibleAbsent(absenceReason) },
|
||||
enabled = !model.busy && absenceReason.trim().length >= 10,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Guardar ausencia") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ElevatedCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Filled.Lock, null, tint = MaterialTheme.colorScheme.error)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Finalizar contenido", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
Text(
|
||||
"Al bloquear el Acta, su contenido y sus Hallazgos quedan inmutables.",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Button(
|
||||
onClick = { model.prepareSelectedAct() },
|
||||
enabled = !model.busy && closure?.responsible != null,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Finalizar y BLOQUEAR Acta") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
"LOCKED" -> {
|
||||
val signatures = closure?.signatures.orEmpty()
|
||||
val inspectorSigned = signatures.any { it.signerType == "INSPECTOR" && it.status == "SIGNED" }
|
||||
val companyOutcome = signatures.firstOrNull { it.signerType == "COMPANY_RESPONSIBLE" }
|
||||
val companyResolved = companyOutcome?.status == "SIGNED" || companyOutcome?.status == "REFUSED"
|
||||
|
||||
ElevatedCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Filled.Lock, null, tint = MaterialTheme.colorScheme.secondary)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Acta BLOQUEADA", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
Text("El contenido ya es inmutable. Restan firmas y manifestaciones para poder sellarla.")
|
||||
closure?.closure?.preparedSha256?.let { Text("Hash · $it", style = MaterialTheme.typography.bodySmall) }
|
||||
|
||||
HorizontalDivider()
|
||||
Text("Firma del inspector", fontWeight = FontWeight.Bold)
|
||||
if (inspectorSigned) {
|
||||
SuccessLine("Firma del inspector registrada")
|
||||
} else {
|
||||
Text(closure?.consents?.inspector.orEmpty(), style = MaterialTheme.typography.bodySmall)
|
||||
SignaturePad(
|
||||
label = "Firmá como inspector/a",
|
||||
enabled = !model.busy,
|
||||
onCaptured = { file -> signWithGeo(file, company = false) },
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
Text("Manifestación de la empresa", fontWeight = FontWeight.Bold)
|
||||
if (companyOutcome != null && companyResolved) {
|
||||
val detail = when (companyOutcome.status) {
|
||||
"SIGNED" -> if (companyOutcome.companyManifestation == "DISSENT") "Firma en disidencia" else "Firma en conformidad"
|
||||
"REFUSED" -> "Negativa a firmar"
|
||||
else -> 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") {
|
||||
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.error)
|
||||
Text(
|
||||
"La ausencia no resuelve la manifestación. Debe registrarse firma o negativa antes de sellar.",
|
||||
Modifier.weight(1f),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text(closure?.consents?.company.orEmpty(), style = MaterialTheme.typography.bodySmall)
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
FilterChip(selected = manifestation == "CONFORMITY", onClick = { manifestation = "CONFORMITY" }, label = { Text("Conforme") })
|
||||
FilterChip(selected = manifestation == "DISSENT", onClick = { manifestation = "DISSENT" }, label = { Text("En disidencia") })
|
||||
}
|
||||
if (manifestation == "DISSENT") {
|
||||
OutlinedTextField(
|
||||
dissentStatement,
|
||||
{ dissentStatement = it },
|
||||
label = { Text("Manifestación de disidencia *") },
|
||||
minLines = 2,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
SignaturePad(
|
||||
label = "Firma del responsable de empresa",
|
||||
enabled = !model.busy && inspectorSigned && (manifestation != "DISSENT" || dissentStatement.trim().length >= 10),
|
||||
onCaptured = { file -> signWithGeo(file, company = true) },
|
||||
)
|
||||
Text("Si se niega a firmar, registrá el motivo.", style = MaterialTheme.typography.bodySmall)
|
||||
OutlinedTextField(
|
||||
refusalReason,
|
||||
{ refusalReason = it },
|
||||
label = { Text("Motivo de negativa") },
|
||||
minLines = 2,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedButton(
|
||||
onClick = { model.recordCompanyOutcome("REFUSED", refusalReason) },
|
||||
enabled = !model.busy && inspectorSigned && refusalReason.trim().length >= 10,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Registrar negativa a firmar") }
|
||||
}
|
||||
|
||||
if (inspectorSigned && companyResolved) {
|
||||
Button(
|
||||
onClick = { model.closeSelectedAct() },
|
||||
enabled = !model.busy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Icon(Icons.Filled.CheckCircle, null)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("SELLAR Acta definitivamente")
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
"La Inspección no puede cerrarse hasta completar las manifestaciones de esta Acta.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
"SEALED" -> {
|
||||
ElevatedCard(Modifier.fillMaxWidth()) {
|
||||
Row(Modifier.padding(18.dp), horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.Top) {
|
||||
Icon(Icons.Filled.CheckCircle, null, tint = MaterialTheme.colorScheme.primary)
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||
Text("Acta SELLADA e inmutable", fontWeight = FontWeight.Bold)
|
||||
Text("Desde este sellado se genera el PDF del Acta y el INF Word editable para el Inspector.")
|
||||
closure?.closure?.finalSha256?.let { Text("SHA-256 · $it", style = MaterialTheme.typography.bodySmall) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
"CANCELLED" -> {
|
||||
Surface(Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.surfaceVariant) {
|
||||
Text("Esta Acta fue cancelada y permanece como antecedente.", Modifier.padding(14.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (visit.status == "IN_PROGRESS" && model.acts.isNotEmpty()) {
|
||||
val activeActs = model.acts.filter { it.status != "CANCELLED" }
|
||||
val pendingActs = activeActs.filter { it.status != "SEALED" }
|
||||
ElevatedCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(9.dp)) {
|
||||
Text("Finalizar Inspección", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
if (pendingActs.isEmpty()) {
|
||||
SuccessLine("Todas las Actas están SELLADAS")
|
||||
} else {
|
||||
Text(
|
||||
"Falta SELLAR: ${pendingActs.joinToString { it.code }}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
Button(
|
||||
onClick = { model.closeInspection() },
|
||||
enabled = !model.busy && activeActs.isNotEmpty() && pendingActs.isEmpty(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Cerrar Inspección y salir") }
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(32.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SuccessLine(text: String) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(7.dp)) {
|
||||
Icon(Icons.Filled.CheckCircle, null, tint = MaterialTheme.colorScheme.primary)
|
||||
Text(text, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
|
||||
private fun modernActStatusLabel(status: String): String = when (status) {
|
||||
"DRAFT" -> "Borrador"
|
||||
"LOCKED" -> "Bloqueada"
|
||||
"SEALED" -> "Sellada"
|
||||
"CANCELLED" -> "Cancelada"
|
||||
else -> status
|
||||
}
|
||||
|
||||
private fun hasModernActLocation(context: Context): Boolean =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
|
||||
|
||||
private suspend fun currentModernActSignatureGeo(context: Context): ModernActSignatureGeo = suspendCancellableCoroutine { continuation ->
|
||||
if (!hasModernActLocation(context)) {
|
||||
continuation.resumeWithException(SecurityException("Ubicación no autorizada"))
|
||||
return@suspendCancellableCoroutine
|
||||
}
|
||||
val source = CancellationTokenSource()
|
||||
try {
|
||||
LocationServices.getFusedLocationProviderClient(context)
|
||||
.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token)
|
||||
.addOnSuccessListener { location ->
|
||||
if (!continuation.isActive) return@addOnSuccessListener
|
||||
if (location == null) continuation.resumeWithException(IllegalStateException("Ubicación no disponible"))
|
||||
else continuation.resume(ModernActSignatureGeo(location.latitude, location.longitude, location.accuracy.toDouble()))
|
||||
}
|
||||
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
|
||||
} catch (error: SecurityException) {
|
||||
if (continuation.isActive) continuation.resumeWithException(error)
|
||||
}
|
||||
continuation.invokeOnCancellation { source.cancel() }
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,8 +8,8 @@ class ReleaseMetadataTest {
|
||||
@Test
|
||||
fun debugBuildKeepsSeparateApplicationIdentity() {
|
||||
assertEquals("com.korexlabs.dhinspeccion.debug", BuildConfig.APPLICATION_ID)
|
||||
assertEquals(26, BuildConfig.VERSION_CODE)
|
||||
assertEquals("0.17.0-debug", BuildConfig.VERSION_NAME)
|
||||
assertEquals(27, BuildConfig.VERSION_CODE)
|
||||
assertEquals("0.18.0-debug", BuildConfig.VERSION_NAME)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -5,10 +5,16 @@ import {
|
||||
InspectionVisitActsController,
|
||||
} from './inspection-acts.controller';
|
||||
import { InspectionActsService } from './inspection-acts.service';
|
||||
import { MobileInspectionActsController } from './mobile-inspection-acts.controller';
|
||||
import { MobileInspectionActsService } from './mobile-inspection-acts.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuditModule],
|
||||
controllers: [InspectionVisitActsController, InspectionActsController],
|
||||
providers: [InspectionActsService],
|
||||
controllers: [
|
||||
InspectionVisitActsController,
|
||||
MobileInspectionActsController,
|
||||
InspectionActsController,
|
||||
],
|
||||
providers: [InspectionActsService, MobileInspectionActsService],
|
||||
})
|
||||
export class InspectionActsModule {}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Controller, Get, Param, ParseUUIDPipe, Query } from '@nestjs/common';
|
||||
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
|
||||
import { ListInspectionActsQueryDto } from './dto/list-inspection-acts-query.dto';
|
||||
import { MobileInspectionActsService } from './mobile-inspection-acts.service';
|
||||
|
||||
@Controller('inspection-visits/:visitId/acts/mobile')
|
||||
export class MobileInspectionActsController {
|
||||
constructor(private readonly acts: MobileInspectionActsService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermissions('inspection_acts.read')
|
||||
list(
|
||||
@Param('visitId', new ParseUUIDPipe({ version: '4' })) visitId: string,
|
||||
@Query() query: ListInspectionActsQueryDto,
|
||||
) {
|
||||
return this.acts.listForVisit(visitId, query);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import type { ListInspectionActsQueryDto } from './dto/list-inspection-acts-query.dto';
|
||||
|
||||
/**
|
||||
* Read model intentionally kept small for the field client.
|
||||
*
|
||||
* The Android app only needs the Act identity/lifecycle, deadlines and counters to
|
||||
* open an Inspection. It must not fail because an office-document/report module is
|
||||
* unavailable or temporarily schema-drifted. The canonical office endpoints keep
|
||||
* their richer projection in InspectionActsService.
|
||||
*/
|
||||
@Injectable()
|
||||
export class MobileInspectionActsService {
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
|
||||
async listForVisit(visitId: string, query: ListInspectionActsQueryDto) {
|
||||
const [visit] = (await this.dataSource.query(
|
||||
'SELECT id FROM inspection_visits WHERE id = $1::uuid',
|
||||
[visitId],
|
||||
)) as Array<{ id: string }>;
|
||||
if (!visit) {
|
||||
throw new NotFoundException({
|
||||
code: 'INSPECTION_VISIT_NOT_FOUND',
|
||||
message: 'Visita de inspección no encontrada',
|
||||
});
|
||||
}
|
||||
|
||||
const conditions = ['act.visit_id = $1::uuid'];
|
||||
const parameters: unknown[] = [visitId];
|
||||
const add = (value: unknown): string => {
|
||||
parameters.push(value);
|
||||
return `$${parameters.length}`;
|
||||
};
|
||||
|
||||
if (query.search?.trim()) {
|
||||
const search = add(`%${query.search.trim()}%`);
|
||||
conditions.push(`(act.code ILIKE ${search} OR act.title ILIKE ${search})`);
|
||||
}
|
||||
if (query.status) conditions.push(`act.status = ${add(query.status)}`);
|
||||
if (query.year) conditions.push(`act.act_year = ${add(query.year)}`);
|
||||
if (query.dateFrom) conditions.push(`act.occurred_at::date >= ${add(query.dateFrom)}::date`);
|
||||
if (query.dateTo) conditions.push(`act.occurred_at::date <= ${add(query.dateTo)}::date`);
|
||||
|
||||
const where = `WHERE ${conditions.join(' AND ')}`;
|
||||
const [countRow] = (await this.dataSource.query(
|
||||
`SELECT COUNT(*)::integer AS total FROM inspection_acts act ${where}`,
|
||||
parameters,
|
||||
)) as Array<{ total: number }>;
|
||||
const total = Number(countRow?.total ?? 0);
|
||||
|
||||
const limit = add(query.pageSize);
|
||||
const offset = add((query.page - 1) * query.pageSize);
|
||||
const data = await this.dataSource.query(`
|
||||
SELECT
|
||||
act.id,
|
||||
act.visit_id AS "visitId",
|
||||
act.code,
|
||||
act.status,
|
||||
act.occurred_at AS "occurredAt",
|
||||
act.title,
|
||||
act.summary,
|
||||
act.observations,
|
||||
act.urgency,
|
||||
act.deadline_days AS "deadlineDays",
|
||||
act.deadline_day_type AS "deadlineDayType",
|
||||
act.deadline_basis AS "deadlineBasis",
|
||||
act.deadline_base_at AS "deadlineBaseAt",
|
||||
act.deadline_at AS "deadlineAt",
|
||||
act.locked_at AS "lockedAt",
|
||||
act.locked_sha256 AS "lockedSha256",
|
||||
act.sealed_at AS "sealedAt",
|
||||
act.current_version AS "currentVersion",
|
||||
act.closed_at AS "closedAt",
|
||||
act.closure_sha256 AS "closureSha256",
|
||||
COALESCE((
|
||||
SELECT COUNT(*)::integer
|
||||
FROM inspection_act_assets link
|
||||
WHERE link.act_id = act.id AND link.included = true
|
||||
), 0)::integer AS "assetCount",
|
||||
COALESCE((
|
||||
SELECT COUNT(*)::integer
|
||||
FROM inspection_findings finding
|
||||
WHERE finding.act_id = act.id AND finding.status <> 'VOIDED'
|
||||
), 0)::integer AS "findingCount"
|
||||
FROM inspection_acts act
|
||||
${where}
|
||||
ORDER BY act.act_year DESC, act.act_number DESC
|
||||
LIMIT ${limit} OFFSET ${offset}
|
||||
`, parameters);
|
||||
|
||||
return {
|
||||
data,
|
||||
meta: {
|
||||
page: query.page,
|
||||
pageSize: query.pageSize,
|
||||
total,
|
||||
totalPages: total === 0 ? 0 : Math.ceil(total / query.pageSize),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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', () => {
|
||||
const gradle = mountedRepoFile('android-app/app/build.gradle.kts');
|
||||
|
||||
assert.match(gradle, /versionCode = 26/);
|
||||
assert.match(gradle, /versionName = "0\.17\.0"/);
|
||||
assert.match(gradle, /versionCode = 27/);
|
||||
assert.match(gradle, /versionName = "0\.18\.0"/);
|
||||
assert.match(gradle, /https:\/\/dhv2\.korexlabs\.com\/api\/v3\//);
|
||||
assert.match(gradle, /applicationIdSuffix = "\.debug"/);
|
||||
});
|
||||
@@ -25,4 +25,4 @@ test('F5/F6.3 field inventory exposes Other families as reviewable choices to An
|
||||
assert.match(service, /F5:SYSTEM:OTHER:%/);
|
||||
assert.match(service, /AS "isOther"/);
|
||||
assert.match(service, /isOtherFamily: family\.isOther/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
function apiFile(path: string): string {
|
||||
return readFileSync(resolve(process.cwd(), path), 'utf8');
|
||||
}
|
||||
|
||||
function repoFile(path: string): string {
|
||||
return readFileSync(resolve(process.cwd(), '..', path), 'utf8');
|
||||
}
|
||||
|
||||
test('F6.4 Android Installation picker searches live and selects a concrete parent', () => {
|
||||
const screen = repoFile('android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernVisitRoot.kt');
|
||||
|
||||
assert.match(screen, /LaunchedEffect\(modeName, parentSearch\)/);
|
||||
assert.match(screen, /delay\(280\)/);
|
||||
assert.match(screen, /model\.searchInventory\(parentSearch\.trim\(\), visit\.scopeAsset\?\.id\)/);
|
||||
assert.match(screen, /ElevatedCard\(onClick = \{ chooseParent\(item\) \}/);
|
||||
assert.match(screen, /model\.loadFieldTypes\(item\.id\)/);
|
||||
});
|
||||
|
||||
test('F6.4 Android field shell uses the modern theme and workspace', () => {
|
||||
const gate = repoFile('android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/LoginGate.kt');
|
||||
const theme = repoFile('android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/DhTheme.kt');
|
||||
|
||||
assert.match(gate, /DhTheme \{/);
|
||||
assert.match(gate, /model\.visit != null -> ModernVisitRoot\(model\)/);
|
||||
assert.match(theme, /RoundedCornerShape\(16\.dp\)/);
|
||||
});
|
||||
|
||||
test('F6.4 Android Act list uses a mobile read model independent from office reports', () => {
|
||||
const android = repoFile('android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/MobileActs.kt');
|
||||
const controller = apiFile('src/inspection-acts/mobile-inspection-acts.controller.ts');
|
||||
const service = apiFile('src/inspection-acts/mobile-inspection-acts.service.ts');
|
||||
|
||||
assert.match(android, /inspection-visits\/\{visitId\}\/acts\/mobile/);
|
||||
assert.match(controller, /inspection-visits\/:visitId\/acts\/mobile/);
|
||||
assert.match(service, /FROM inspection_acts act/);
|
||||
assert.doesNotMatch(service, /inspection_reports/);
|
||||
assert.doesNotMatch(service, /inspection_document/);
|
||||
});
|
||||
Reference in New Issue
Block a user