F4 Android: redesign Act screen for DRAFT LOCKED SEALED

This commit is contained in:
2026-09-07 21:34:45 -03:00
parent ef084dd899
commit 73c69c7a62
@@ -60,6 +60,7 @@ fun MobileActsScreen(
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var newActUrgency by rememberSaveable(visit.id) { mutableStateOf("NON_URGENT") }
var attendance by rememberSaveable(selected?.id) { var attendance by rememberSaveable(selected?.id) {
mutableStateOf(closure?.responsible?.attendanceStatus ?: "PRESENT") mutableStateOf(closure?.responsible?.attendanceStatus ?: "PRESENT")
} }
@@ -129,19 +130,20 @@ fun MobileActsScreen(
Card(Modifier.fillMaxWidth()) { Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Actas de esta inspección", fontWeight = FontWeight.Bold) Text("Actas de esta Inspección", fontWeight = FontWeight.Bold)
if (model.acts.isEmpty()) { if (model.acts.isEmpty()) {
Text("Todavía no hay Actas. La primera se inicia sobre una Instalación/Subinstalación seleccionada.") Text("Todavía no hay Actas. La primera se inicia sobre una Instalación/Subinstalación seleccionada.")
} }
model.acts.forEach { act -> model.acts.forEach { act ->
val active = selected?.id == act.id val active = selected?.id == act.id
val urgency = if (act.urgency == "URGENT") "URGENTE" else "No urgente"
OutlinedButton( OutlinedButton(
onClick = { model.selectAct(act.id) }, onClick = { model.selectAct(act.id) },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
Text( Text(
(if (active) "" else "") + (if (active) "" else "") +
"${act.code} · ${actStatusLabel(act.status)} · ${act.findingCount} Hallazgo${if (act.findingCount == 1) "" else "s"}", "${act.code} · ${actStatusLabel(act.status)} · $urgency · ${act.findingCount} Hallazgo${if (act.findingCount == 1) "" else "s"}",
) )
} }
} }
@@ -156,12 +158,31 @@ fun MobileActsScreen(
) { ) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Nueva Acta", fontWeight = FontWeight.Bold) Text("Nueva Acta", fontWeight = FontWeight.Bold)
if (model.acts.any { it.status == "READY" }) { if (model.acts.any { it.status == "LOCKED" }) {
Text( Text(
"Puede existir una nueva Acta en borrador aunque haya Actas preparadas pendientes de firma de empresa. Sólo se permite un borrador a la vez.", "Podés abrir una nueva Acta aunque otra esté BLOQUEADA esperando firmas. Sólo se permite un borrador a la vez.",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
) )
} }
Text("Urgencia del Acta", fontWeight = FontWeight.Bold)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
AssistChip(
onClick = { newActUrgency = "NON_URGENT" },
label = { Text(if (newActUrgency == "NON_URGENT") "✓ No urgente" else "No urgente") },
)
AssistChip(
onClick = { newActUrgency = "URGENT" },
label = { Text(if (newActUrgency == "URGENT") "✓ Urgente" else "Urgente") },
)
}
Text(
if (newActUrgency == "URGENT") {
"El plazo urgente se computará desde el Acta según la política institucional vigente al bloquearla."
} else {
"El plazo no urgente se computará desde la oficialización GEDO según la política institucional vigente al bloquearla."
},
style = MaterialTheme.typography.bodySmall,
)
val selectedInventory = model.selectedFieldAsset?.asset val selectedInventory = model.selectedFieldAsset?.asset
if (selectedInventory == null) { if (selectedInventory == null) {
Text("Primero elegí una Instalación o Subinstalación desde Inventario de campo. Ese registro será el primer elemento del Acta.") Text("Primero elegí una Instalación o Subinstalación desde Inventario de campo. Ese registro será el primer elemento del Acta.")
@@ -171,7 +192,7 @@ fun MobileActsScreen(
} else { } else {
Text("Inventario inicial: ${selectedInventory.name} · ${selectedInventory.code}") Text("Inventario inicial: ${selectedInventory.name} · ${selectedInventory.code}")
Button( Button(
onClick = { model.createActForSelectedInventory() }, onClick = { model.createActForSelectedInventory(newActUrgency) },
enabled = !model.busy, enabled = !model.busy,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { Text("Crear nueva Acta") } ) { Text("Crear nueva Acta") }
@@ -186,9 +207,14 @@ fun MobileActsScreen(
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) { Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
Text(selected.code, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) Text(selected.code, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text(actStatusLabel(selected.status)) Text(actStatusLabel(selected.status))
Text(if (selected.urgency == "URGENT") "Urgente" else "No urgente")
Text("${selected.findingCount} Hallazgo${if (selected.findingCount == 1) "" else "s"} · ${selected.assetCount} elemento${if (selected.assetCount == 1) "" else "s"} de Inventario") 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.deadlineAt?.let { Text("Vencimiento calculado: $it", style = MaterialTheme.typography.bodySmall) }
selected.closureSha256?.let { Text("Hash final: $it", style = MaterialTheme.typography.bodySmall) } if (selected.deadlineAt == null && selected.deadlineBasis == "GEDO_DATE") {
Text("Vencimiento pendiente de fecha GEDO.", style = MaterialTheme.typography.bodySmall)
}
selected.lockedSha256?.let { Text("Hash bloqueado: $it", style = MaterialTheme.typography.bodySmall) }
selected.closureSha256?.let { Text("Hash sellado: $it", style = MaterialTheme.typography.bodySmall) }
} }
} }
@@ -234,31 +260,28 @@ fun MobileActsScreen(
HorizontalDivider() HorizontalDivider()
Text("2. Hallazgos / verificaciones", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) Text("2. Hallazgos / verificaciones", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text("Los Hallazgos se cargan desde Inventario y quedan vinculados a ${selected.code} de forma explícita. Un Acta de verificación puede prepararse sin Hallazgos nuevos si la verificación ya fue registrada.") Text("Los Hallazgos se cargan desde Inventario y quedan vinculados explícitamente a ${selected.code}. El Acta también puede finalizar sin Hallazgos cuando corresponde dejar constancia de una inspección o verificación sin nuevos incumplimientos.")
Button(onClick = onGoInventory, modifier = Modifier.fillMaxWidth()) { Text("Ir a Inventario / Hallazgos") } Button(onClick = onGoInventory, modifier = Modifier.fillMaxWidth()) { Text("Ir a Inventario / Hallazgos") }
HorizontalDivider() HorizontalDivider()
Text("3. Preparar Acta", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) Text("3. Finalizar contenido", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text("Al preparar, el contenido se congela y se calcula su hash. El servidor exige al menos un Hallazgo o una verificación registrada.") Text("Al BLOQUEAR el Acta, el contenido y los Hallazgos quedan inmutables. Esta acción no se puede deshacer.")
Button( Button(
onClick = { model.prepareSelectedAct() }, onClick = { model.prepareSelectedAct() },
enabled = !model.busy && closure?.responsible != null, enabled = !model.busy && closure?.responsible != null,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { Text("Preparar Acta para firmas") } ) { Text("Finalizar y BLOQUEAR Acta") }
} }
"READY" -> { "LOCKED" -> {
val signatures = closure?.signatures.orEmpty() val signatures = closure?.signatures.orEmpty()
val inspectorSigned = signatures.any { it.signerType == "INSPECTOR" && it.status == "SIGNED" } val inspectorSigned = signatures.any { it.signerType == "INSPECTOR" && it.status == "SIGNED" }
val companyOutcome = signatures.firstOrNull { it.signerType == "COMPANY_RESPONSIBLE" } val companyOutcome = signatures.firstOrNull { it.signerType == "COMPANY_RESPONSIBLE" }
val companyResolved = companyOutcome?.status == "SIGNED" || companyOutcome?.status == "REFUSED"
Text("Acta preparada", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) Text("Acta BLOQUEADA", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
closure?.closure?.preparedSha256?.let { Text("Hash preparado: $it", style = MaterialTheme.typography.bodySmall) } Text("El contenido ya es inmutable. Sólo resta resolver firmas y manifestaciones para poder SELLARLA.")
if (signatures.isEmpty()) { closure?.closure?.preparedSha256?.let { Text("Hash bloqueado: $it", style = MaterialTheme.typography.bodySmall) }
OutlinedButton(onClick = { model.reopenSelectedAct() }, enabled = !model.busy, modifier = Modifier.fillMaxWidth()) {
Text("Volver a borrador")
}
}
HorizontalDivider() HorizontalDivider()
Text("Firma del inspector", fontWeight = FontWeight.Bold) Text("Firma del inspector", fontWeight = FontWeight.Bold)
@@ -274,29 +297,21 @@ fun MobileActsScreen(
} }
HorizontalDivider() HorizontalDivider()
Text("Recepción de la empresa", fontWeight = FontWeight.Bold) Text("Manifestación de la empresa", fontWeight = FontWeight.Bold)
if (companyOutcome != null) { if (companyOutcome != null && companyResolved) {
val detail = when (companyOutcome.status) { val detail = when (companyOutcome.status) {
"SIGNED" -> if (companyOutcome.companyManifestation == "DISSENT") "Firma en disidencia" else "Firma registrada" "SIGNED" -> if (companyOutcome.companyManifestation == "DISSENT") "Firma en disidencia" else "Firma en conformidad"
"REFUSED" -> "Negativa a firmar" "REFUSED" -> "Negativa a firmar"
"ABSENT" -> "Responsable ausente"
else -> companyOutcome.status else -> companyOutcome.status
} }
Text("$detail", color = MaterialTheme.colorScheme.primary) Text("$detail", color = MaterialTheme.colorScheme.primary)
companyOutcome.reason?.let { Text(it, style = MaterialTheme.typography.bodySmall) } companyOutcome.reason?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
companyOutcome.companyStatement?.let { Text(it, style = MaterialTheme.typography.bodySmall) } companyOutcome.companyStatement?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
} else if (closure?.responsible?.attendanceStatus == "ABSENT") { } else if (closure?.responsible?.attendanceStatus == "ABSENT") {
Text("El responsable fue registrado como ausente.") Text(
Button( "El responsable fue registrado como ausente. La ausencia NO resuelve la manifestación: deberá obtenerse firma o negativa posteriormente antes de SELLAR el Acta.",
onClick = { style = MaterialTheme.typography.bodyMedium,
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 { } else {
Text(closure?.consents?.company.orEmpty(), style = MaterialTheme.typography.bodySmall) Text(closure?.consents?.company.orEmpty(), style = MaterialTheme.typography.bodySmall)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
@@ -333,28 +348,28 @@ fun MobileActsScreen(
} }
HorizontalDivider() HorizontalDivider()
if (inspectorSigned && companyOutcome != null) { if (inspectorSigned && companyResolved) {
Button( Button(
onClick = { model.closeSelectedAct() }, onClick = { model.closeSelectedAct() },
enabled = !model.busy, enabled = !model.busy,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { Text("Cerrar Acta definitivamente") } ) { Text("SELLAR Acta definitivamente") }
} else if (inspectorSigned) { } else {
Text( Text(
"La inspección puede finalizar con la firma de empresa pendiente; el Acta permanecerá preparada hasta registrar firma, negativa o ausencia.", "Esta Acta bloquea el cierre de la Inspección hasta tener firma de inspector y firma o negativa válida de la empresa.",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
) )
} }
} }
"CLOSED" -> { "SEALED" -> {
Card( Card(
Modifier.fillMaxWidth(), Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
) { ) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) { Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
Text("Acta cerrada e inmutable", fontWeight = FontWeight.Bold) Text("Acta SELLADA e inmutable", fontWeight = FontWeight.Bold)
Text("El PDF, informe Word y entregas documentales se generan desde este cierre.") 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) } closure?.closure?.finalSha256?.let { Text("SHA-256: $it", style = MaterialTheme.typography.bodySmall) }
} }
} }
@@ -366,16 +381,19 @@ fun MobileActsScreen(
if (visit.status == "IN_PROGRESS" && model.acts.isNotEmpty()) { if (visit.status == "IN_PROGRESS" && model.acts.isNotEmpty()) {
HorizontalDivider() HorizontalDivider()
val drafts = model.acts.count { it.status == "DRAFT" } val activeActs = model.acts.filter { it.status != "CANCELLED" }
Text("Finalizar inspección", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) val pendingActs = activeActs.filter { it.status != "SEALED" }
Text( Text("Finalizar Inspección", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
"No puede quedar ninguna Acta en borrador. Al cerrar, el servidor verifica que cada Acta preparada tenga firma de inspector; si falta alguna, indicará cuál debe completarse.", if (pendingActs.isEmpty()) {
) Text("Todas las Actas están SELLADAS. La Inspección ya puede cerrarse.")
} else {
Text("No puede cerrarse todavía. Falta SELLAR: ${pendingActs.joinToString { it.code }}")
}
Button( Button(
onClick = { model.closeInspection() }, onClick = { model.closeInspection() },
enabled = !model.busy && drafts == 0, enabled = !model.busy && activeActs.isNotEmpty() && pendingActs.isEmpty(),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { Text("Cerrar inspección y salir de la empresa") } ) { Text("Cerrar Inspección y salir de la empresa") }
} }
} }
} }
@@ -397,8 +415,8 @@ private fun F32ActMessage(model: MainViewModel) {
private fun actStatusLabel(status: String): String = when (status) { private fun actStatusLabel(status: String): String = when (status) {
"DRAFT" -> "Borrador" "DRAFT" -> "Borrador"
"READY" -> "Preparada para firmas" "LOCKED" -> "BLOQUEADA"
"CLOSED" -> "Cerrada" "SEALED" -> "SELLADA"
"CANCELLED" -> "Cancelada" "CANCELLED" -> "Cancelada"
else -> status else -> status
} }