From 97a774c2649a7cbf49fa87570dac90431086b5b9 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 16:20:08 -0300 Subject: [PATCH] =?UTF-8?q?F3.2=20Android:=20implementar=20gesti=C3=B3n=20?= =?UTF-8?q?y=20cierre=20secuencial=20de=20Actas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dhinspeccion/ui/MobileActsScreen.kt | 423 ++++++++++++++++++ 1 file changed, 423 insertions(+) create mode 100644 android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/MobileActsScreen.kt diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/MobileActsScreen.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/MobileActsScreen.kt new file mode 100644 index 0000000..783478b --- /dev/null +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/MobileActsScreen.kt @@ -0,0 +1,423 @@ +package com.korexlabs.dhinspeccion.ui + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AssistChip +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +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.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +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 ActSignatureGeo( + val latitude: Double, + val longitude: Double, + val accuracyM: Double?, +) + +@Composable +fun MobileActsScreen( + 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 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 { currentActSignatureGeo(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(top = 28.dp, start = 16.dp, end = 16.dp, bottom = 36.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + OutlinedButton(onClick = onBack, enabled = !model.busy) { Text("Volver") } + Text("Actas", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + } + Text("${visit.code} · ${visit.operatorCompany?.name.orEmpty()}") + F32ActMessage(model) + + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Actas de esta inspección", fontWeight = FontWeight.Bold) + if (model.acts.isEmpty()) { + Text("Todavía no hay Actas. La primera se inicia sobre una Instalación/Subinstalación seleccionada.") + } + model.acts.forEach { act -> + val active = selected?.id == act.id + OutlinedButton( + onClick = { model.selectAct(act.id) }, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + (if (active) "✓ " else "") + + "${act.code} · ${actStatusLabel(act.status)} · ${act.findingCount} Hallazgo${if (act.findingCount == 1) "" else "s"}", + ) + } + } + } + } + + val hasOpenAct = model.acts.any { it.status == "DRAFT" || it.status == "READY" } + if (visit.status == "IN_PROGRESS" && !hasOpenAct) { + Card( + Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer), + ) { + Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Nueva Acta", fontWeight = FontWeight.Bold) + val selectedInventory = model.selectedFieldAsset?.asset + if (selectedInventory == null) { + Text("Primero elegí una Instalación o Subinstalación desde Inventario de campo. Ese registro será el primer elemento del Acta.") + Button(onClick = onGoInventory, modifier = Modifier.fillMaxWidth()) { + Text("Ir a Inventario y elegir") + } + } else { + Text("Inventario inicial: ${selectedInventory.name} · ${selectedInventory.code}") + Button( + onClick = { model.createActForSelectedInventory() }, + enabled = !model.busy, + modifier = Modifier.fillMaxWidth(), + ) { Text("Crear nueva Acta") } + } + } + } + } + + if (selected != null) { + HorizontalDivider() + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) { + Text(selected.code, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Text(actStatusLabel(selected.status)) + Text("${selected.findingCount} Hallazgo${if (selected.findingCount == 1) "" else "s"} · ${selected.assetCount} elemento${if (selected.assetCount == 1) "" else "s"} de Inventario") + Text(selected.summary, style = MaterialTheme.typography.bodySmall) + selected.closureSha256?.let { Text("Hash final: $it", style = MaterialTheme.typography.bodySmall) } + } + } + + when (selected.status) { + "DRAFT" -> { + Text("1. Responsable de la empresa", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + AssistChip(onClick = { attendance = "PRESENT" }, label = { Text(if (attendance == "PRESENT") "✓ Presente" else "Presente") }) + AssistChip(onClick = { attendance = "ABSENT" }, label = { Text(if (attendance == "ABSENT") "✓ Ausente" else "Ausente") }) + } + if (attendance == "PRESENT") { + OutlinedTextField(fullName, { fullName = it }, label = { Text("Nombre y apellido *") }, modifier = Modifier.fillMaxWidth()) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) { + listOf("DNI", "CUIL", "PASSPORT", "OTHER").forEach { kind -> + AssistChip(onClick = { documentType = kind }, label = { Text(if (documentType == kind) "✓ $kind" else kind) }) + } + } + OutlinedTextField(documentNumber, { documentNumber = it }, label = { Text("Documento *") }, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(position, { position = it }, label = { Text("Cargo *") }, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(email, { email = it }, label = { Text("Email") }, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(phone, { phone = it }, label = { Text("Teléfono") }, modifier = Modifier.fillMaxWidth()) + 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") } + } + + HorizontalDivider() + Text("2. Hallazgos", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Text("Los Hallazgos se cargan desde Inventario y quedan vinculados a ${selected.code} de forma explícita.") + Button(onClick = onGoInventory, modifier = Modifier.fillMaxWidth()) { Text("Ir a Inventario / Hallazgos") } + + HorizontalDivider() + Text("3. Preparar Acta", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Text("Al preparar, el contenido se congela y se calcula su hash. Después sólo se admiten firmas y resultado de empresa.") + Button( + onClick = { model.prepareSelectedAct() }, + enabled = !model.busy && closure?.responsible != null && selected.findingCount > 0, + modifier = Modifier.fillMaxWidth(), + ) { Text("Preparar Acta para firmas") } + } + + "READY" -> { + val signatures = closure?.signatures.orEmpty() + val inspectorSigned = signatures.any { it.signerType == "INSPECTOR" && it.status == "SIGNED" } + val companyOutcome = signatures.firstOrNull { it.signerType == "COMPANY_RESPONSIBLE" } + + Text("Acta preparada", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + closure?.closure?.preparedSha256?.let { Text("Hash preparado: $it", style = MaterialTheme.typography.bodySmall) } + if (signatures.isEmpty()) { + OutlinedButton(onClick = { model.reopenSelectedAct() }, enabled = !model.busy, modifier = Modifier.fillMaxWidth()) { + Text("Volver a borrador") + } + } + + HorizontalDivider() + Text("Firma del inspector", fontWeight = FontWeight.Bold) + if (inspectorSigned) { + Text("✓ Firma del inspector registrada", color = MaterialTheme.colorScheme.primary) + } 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("Recepción de la empresa", fontWeight = FontWeight.Bold) + if (companyOutcome != null) { + val detail = when (companyOutcome.status) { + "SIGNED" -> if (companyOutcome.companyManifestation == "DISSENT") "Firma en disidencia" else "Firma registrada" + "REFUSED" -> "Negativa a firmar" + "ABSENT" -> "Responsable ausente" + else -> companyOutcome.status + } + Text("✓ $detail", color = MaterialTheme.colorScheme.primary) + companyOutcome.reason?.let { Text(it, style = MaterialTheme.typography.bodySmall) } + companyOutcome.companyStatement?.let { Text(it, style = MaterialTheme.typography.bodySmall) } + } else if (closure?.responsible?.attendanceStatus == "ABSENT") { + Text("El responsable fue registrado como ausente.") + Button( + onClick = { + model.recordCompanyOutcome( + "ABSENT", + closure.responsible.absenceReason ?: "Responsable de empresa ausente durante la inspección", + ) + }, + enabled = !model.busy && inspectorSigned, + modifier = Modifier.fillMaxWidth(), + ) { Text("Asentar ausencia en el Acta") } + } else { + Text(closure?.consents?.company.orEmpty(), style = MaterialTheme.typography.bodySmall) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + AssistChip(onClick = { manifestation = "CONFORMITY" }, label = { Text(if (manifestation == "CONFORMITY") "✓ Conforme" else "Conforme") }) + AssistChip(onClick = { manifestation = "DISSENT" }, label = { Text(if (manifestation == "DISSENT") "✓ En disidencia" else "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 la persona presente se niega a firmar, asentá el motivo en lugar de dibujar una firma.", 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") } + } + + HorizontalDivider() + if (inspectorSigned && companyOutcome != null) { + Button( + onClick = { model.closeSelectedAct() }, + enabled = !model.busy, + modifier = Modifier.fillMaxWidth(), + ) { Text("Cerrar Acta definitivamente") } + } else if (inspectorSigned) { + Text( + "La inspección puede finalizar con la firma de empresa pendiente; el Acta permanecerá preparada hasta registrar firma, negativa o ausencia.", + style = MaterialTheme.typography.bodySmall, + ) + } + } + + "CLOSED" -> { + Card( + Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer), + ) { + Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) { + Text("Acta cerrada e inmutable", fontWeight = FontWeight.Bold) + Text("El PDF, informe Word y entregas documentales se generan desde este cierre.") + closure?.closure?.finalSha256?.let { Text("SHA-256: $it", style = MaterialTheme.typography.bodySmall) } + } + } + } + + "CANCELLED" -> Text("Esta Acta fue cancelada y permanece sólo como antecedente.") + } + } + + if (visit.status == "IN_PROGRESS" && model.acts.isNotEmpty()) { + HorizontalDivider() + val drafts = model.acts.count { it.status == "DRAFT" } + val readyMissingInspector = model.acts.any { summary -> + if (summary.status != "READY") false + else if (selected?.id == summary.id) { + model.actClosure?.signatures?.none { it.signerType == "INSPECTOR" && it.status == "SIGNED" } ?: true + } else true + } + Text("Finalizar inspección", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Text("No puede quedar ninguna Acta en borrador. Cada Acta preparada debe tener firma de inspector.") + Button( + onClick = { model.closeInspection() }, + enabled = !model.busy && drafts == 0 && !readyMissingInspector, + modifier = Modifier.fillMaxWidth(), + ) { Text("Cerrar inspección y salir de la empresa") } + } + } +} + +@Composable +private fun F32ActMessage(model: MainViewModel) { + val text = model.error ?: model.notice ?: return + Card( + Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = if (model.error != null) MaterialTheme.colorScheme.errorContainer + else MaterialTheme.colorScheme.secondaryContainer, + ), + onClick = { model.clearMessages() }, + ) { + Text(text, Modifier.padding(12.dp)) + } +} + +private fun actStatusLabel(status: String): String = when (status) { + "DRAFT" -> "Borrador" + "READY" -> "Preparada para firmas" + "CLOSED" -> "Cerrada" + "CANCELLED" -> "Cancelada" + else -> status +} + +private fun hasActLocation(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 currentActSignatureGeo(context: Context): ActSignatureGeo = suspendCancellableCoroutine { continuation -> + if (!hasActLocation(context)) { + continuation.resumeWithException(SecurityException("Ubicación no autorizada")) + return@suspendCancellableCoroutine + } + val source = CancellationTokenSource() + 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(ActSignatureGeo(location.latitude, location.longitude, location.accuracy.toDouble())) + } + .addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) } + continuation.invokeOnCancellation { source.cancel() } +}