Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47cd985931 | ||
|
|
da7c1ddb55 | ||
|
|
a2ec846721 | ||
|
|
9eef156197 | ||
|
|
08f164a209 | ||
|
|
23450bb6d0 | ||
|
|
20ccba5292 |
@@ -1,5 +1,5 @@
|
||||
name: Android CI / RC
|
||||
# F6.2 field closing barrier: lint + real tests + debug artifact + release compile.
|
||||
# F6.7 field closing barrier: lint + real tests + debug artifact + release compile.
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -111,7 +111,7 @@ jobs:
|
||||
cp android-app/app/build/outputs/apk/debug/app-debug.apk "$apk"
|
||||
sha256sum "$apk" > "${apk}.sha256"
|
||||
{
|
||||
echo "phase=F6.2"
|
||||
echo "phase=F6.7"
|
||||
echo "version=$version"
|
||||
echo "versionCode=$code"
|
||||
echo "commit=$GITHUB_SHA"
|
||||
|
||||
+22
-7
@@ -1,20 +1,31 @@
|
||||
# DH Inspección Android · release final de campo 0.19.3
|
||||
# DH Inspección Android · release final de campo 0.19.9
|
||||
|
||||
## Candidata vigente
|
||||
|
||||
- Fase funcional: **Flujo final de campo · Inspección → Acta → Hallazgos → Firma**.
|
||||
- `versionName`: **0.19.3**.
|
||||
- `versionCode`: **31**.
|
||||
- `versionName`: **0.19.9**.
|
||||
- `versionCode`: **37**.
|
||||
- Application ID release: `com.korexlabs.dhinspeccion`.
|
||||
- Application ID debug/QA: `com.korexlabs.dhinspeccion.debug`.
|
||||
- API: `https://dhv2.korexlabs.com/api/v3/`.
|
||||
- Servidor compatible de esta candidata: **API 0.29.0-4 / WEB 0.23.0-3**.
|
||||
- Servidor compatible de esta candidata: **API 0.29.0-7 / WEB 0.23.0-4**.
|
||||
|
||||
La variante debug es independiente de la app productiva y puede instalarse para QA/presentación sin sobrescribir una instalación release histórica.
|
||||
|
||||
### Ajustes 0.19.9
|
||||
|
||||
- Corrige el cierre de Actas evitando `NaN` en el versionado de LOCKED/SEALED.
|
||||
- El cierre pasa a **Para firmar**; la APK captura sólo la manifestación/firma del acompañante.
|
||||
- La firma del Inspector se guarda una vez en Mi perfil y se copia de forma inmutable al sellar cada Acta.
|
||||
- Elimina la tarjeta duplicada del Acta seleccionada y simplifica la jerarquía visual inspirada en la referencia Figma.
|
||||
|
||||
- El encabezado de **Mis inspecciones** separa las acciones para que **Salir** nunca se comprima ni se corte en pantallas angostas.
|
||||
- El alta de Hallazgos corrige la captura de versión que podía transformar el número en `NaN` después del INSERT y devolver error 500.
|
||||
- Un Hallazgo admite **múltiples fotografías con GPS**; después de cada captura queda disponible **Agregar otra foto con GPS** y se muestra el contador.
|
||||
|
||||
## Procedimiento operativo validado
|
||||
|
||||
La APK 0.19.3 fija como recorrido principal de campo:
|
||||
La APK 0.19.9 fija como recorrido principal de campo:
|
||||
|
||||
1. **Iniciar Inspección**. Al iniciarla se habilita el circuito de **Actas y Hallazgos**; Inventario no es una acción independiente de la Inspección.
|
||||
2. **Crear o abrir un Acta**. Puede haber varias Actas dentro de una misma Inspección, pero sólo una puede permanecer en elaboración al mismo tiempo. La urgencia todavía no se define.
|
||||
@@ -23,8 +34,8 @@ La APK 0.19.3 fija como recorrido principal de campo:
|
||||
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. Antes del cierre se identifican, para esa Acta, los datos del **Representante de la empresa** que acompañó el recorrido: nombres y apellidos, DNI, cargo/función y email. Si coincide con el Acta anterior, la APK puede precargarlos, pero deben confirmarse nuevamente.
|
||||
8. Al terminar el contenido del Acta se elige **Urgente / No urgente** y luego se usa **Cerrar Acta y dejar pendiente de firma**. La urgencia pertenece al Acta completa, nunca a cada Hallazgo; desde ese momento el contenido queda inmutable y el estado visible es **Pendiente de firma**.
|
||||
9. La firma del Inspector y la manifestación del representante completan el Acta: **conformidad**, **disconformidad con motivo obligatorio** o **negativa a firmar con motivo obligatorio**. La firma se registra Acta por Acta aunque sea la misma persona.
|
||||
8. Al terminar el contenido del Acta se elige **Urgente / No urgente** y luego se usa **Cerrar contenido y pasar a firma**. La urgencia pertenece al Acta completa, nunca a cada Hallazgo; desde ese momento el contenido queda inmutable y el estado visible es **Para firmar**.
|
||||
9. En **Para firmar**, el acompañante de la empresa registra su manifestación: **conformidad**, **disconformidad con motivo obligatorio**, **negativa a firmar con motivo obligatorio** o **ausencia documentada**. La firma del Inspector no se dibuja en la tablet: se guarda una sola vez desde **Mi perfil** del Dashboard y el servidor copia esa firma de forma inmutable a cada Acta al usar **Aplicar mi firma y cerrar Acta**.
|
||||
10. La Inspección sólo puede cerrarse cuando todas sus Actas no canceladas están firmadas y cerradas.
|
||||
|
||||
La nomenclatura técnica interna de API (`DRAFT`, `LOCKED`, `SEALED`, etc.) no se muestra al inspector: la interfaz usa textos operativos en castellano.
|
||||
@@ -51,6 +62,10 @@ Además de lo anterior requiere elegir primero la **Instalación padre**. El bus
|
||||
|
||||
La clasificación y los atributos se obtienen dinámicamente desde el mismo catálogo administrado por el Dashboard; la APK no mantiene listas técnicas paralelas.
|
||||
|
||||
## Alta de Hallazgos
|
||||
|
||||
El alta de Hallazgos usa un selector desplegable con búsqueda por nombre. La pantalla muestra sólo la información necesaria para decidir, mantiene **Otro / No está en la lista** como salida manual y evita exponer códigos o leyendas internas del catálogo. La fecha de corrección no se solicita al inspector al crear un Hallazgo; los plazos administrativos se gestionan fuera de este alta de campo. Los campos de carga son compactos y el teclado avanza con **Siguiente** entre ellos.
|
||||
|
||||
## Actas y firma
|
||||
|
||||
La terminología visible se simplifica:
|
||||
|
||||
@@ -12,8 +12,8 @@ android {
|
||||
applicationId = "com.korexlabs.dhinspeccion"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 33
|
||||
versionName = "0.19.5"
|
||||
versionCode = 37
|
||||
versionName = "0.19.9"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables.useSupportLibrary = true
|
||||
|
||||
@@ -316,7 +316,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
)
|
||||
selectedFieldAsset = selectedFieldAsset?.copy(capture = response.capture)
|
||||
notice = if (response.capture.readyForFinding) {
|
||||
"Captura completa: GPS y fotografía registrados."
|
||||
"Inventario listo: ubicación y foto registradas."
|
||||
} else {
|
||||
"Fotografía registrada."
|
||||
}
|
||||
@@ -353,7 +353,6 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
customLegalBasis: String?,
|
||||
description: String,
|
||||
severity: Int?,
|
||||
correctionDueOn: String?,
|
||||
) {
|
||||
val visitId = visit?.id ?: return
|
||||
val assetId = selectedFieldAsset?.asset?.id ?: return
|
||||
@@ -386,7 +385,6 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
customLegalBasis = customLegalBasis?.trim()?.takeIf { it.isNotBlank() },
|
||||
description = description.trim(),
|
||||
severity = severity,
|
||||
correctionDueOn = correctionDueOn?.trim()?.takeIf { it.isNotBlank() },
|
||||
),
|
||||
)
|
||||
lastCreatedFinding = response.finding
|
||||
|
||||
@@ -561,8 +561,10 @@ class DhRepository(context: Context) {
|
||||
if (error is HttpException) {
|
||||
val body = runCatching { error.response()?.errorBody()?.string() }.getOrNull()
|
||||
val message = runCatching { JSONObject(body.orEmpty()).optString("message") }.getOrNull()
|
||||
if (message == "Error interno") return "No se pudo completar la operación. Intentá nuevamente."
|
||||
if (!message.isNullOrBlank()) return message
|
||||
return "Error HTTP ${error.code()}"
|
||||
if (error.code() >= 500) return "No se pudo completar la operación. Intentá nuevamente."
|
||||
return "No se pudo procesar la solicitud (${error.code()})."
|
||||
}
|
||||
return error.message ?: "Ocurrió un error inesperado"
|
||||
}
|
||||
|
||||
@@ -113,7 +113,6 @@ data class CreateFieldFindingRequest(
|
||||
val customLegalBasis: String? = null,
|
||||
val description: String,
|
||||
val severity: Int? = null,
|
||||
val correctionDueOn: String? = null,
|
||||
)
|
||||
|
||||
data class FieldFindingCreateResponse(
|
||||
|
||||
+116
-64
@@ -7,21 +7,27 @@ import android.net.Uri
|
||||
import android.os.Environment
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.clickable
|
||||
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.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.MenuAnchorType
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
@@ -34,8 +40,12 @@ 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.focus.FocusDirection
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
@@ -60,20 +70,23 @@ private data class FindingGeoSnapshot(
|
||||
val accuracyM: Double?,
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun FieldFindingScreen(model: MainViewModel) {
|
||||
val options = model.fieldFindingOptions ?: return
|
||||
val asset = model.selectedFieldAsset?.asset ?: return
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val focusManager = LocalFocusManager.current
|
||||
val keyboard = LocalSoftwareKeyboardController.current
|
||||
var search by rememberSaveable(asset.id) { mutableStateOf("") }
|
||||
var catalogExpanded by rememberSaveable(asset.id) { mutableStateOf(false) }
|
||||
var selectedCatalogId by rememberSaveable(asset.id) { mutableStateOf<String?>(null) }
|
||||
var other by rememberSaveable(asset.id) { mutableStateOf(false) }
|
||||
var customTitle by rememberSaveable(asset.id) { mutableStateOf("") }
|
||||
var customLegalBasis by rememberSaveable(asset.id) { mutableStateOf("") }
|
||||
var description by rememberSaveable(asset.id) { mutableStateOf("") }
|
||||
var severityText by rememberSaveable(asset.id) { mutableStateOf("") }
|
||||
var correctionDueOn by rememberSaveable(asset.id) { mutableStateOf("") }
|
||||
|
||||
var localError by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var requestedFindingId by remember { mutableStateOf<String?>(null) }
|
||||
@@ -164,8 +177,8 @@ fun FieldFindingScreen(model: MainViewModel) {
|
||||
customLegalBasis = ""
|
||||
description = ""
|
||||
severityText = ""
|
||||
correctionDueOn = ""
|
||||
search = ""
|
||||
catalogExpanded = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,15 +193,15 @@ fun FieldFindingScreen(model: MainViewModel) {
|
||||
OutlinedButton(onClick = { model.clearFindingFlow() }, enabled = !model.busy) {
|
||||
Text("Volver")
|
||||
}
|
||||
Text("Hallazgo de campo", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||
Text("Nuevo Hallazgo", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(asset.name, fontWeight = FontWeight.Bold)
|
||||
Text("${asset.code} · Acta ${options.act.code}", style = MaterialTheme.typography.bodySmall)
|
||||
Text("${asset.code} · ${options.act.code}", style = MaterialTheme.typography.bodySmall)
|
||||
Text(
|
||||
"GPS + foto del Inventario: ${if (options.capture.readyForFinding) "completo" else "pendiente"}",
|
||||
if (options.capture.readyForFinding) "Inventario listo para registrar Hallazgos" else "Completá GPS y foto antes de continuar",
|
||||
color = if (options.capture.readyForFinding) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
@@ -225,87 +238,123 @@ fun FieldFindingScreen(model: MainViewModel) {
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
val photoCount = evidence.count { it.kind == "PHOTO" }
|
||||
Button(
|
||||
onClick = { requestPhoto(finding.id) },
|
||||
enabled = !model.busy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Tomar foto con GPS")
|
||||
Text(if (photoCount == 0) "Agregar foto con GPS" else "Agregar otra foto con GPS")
|
||||
}
|
||||
Text(
|
||||
if (photoCount == 0) "Podés adjuntar una o varias fotos." else "$photoCount foto${if (photoCount == 1) "" else "s"} adjunta${if (photoCount == 1) "" else "s"}. Podés seguir agregando.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
Text("1. Elegí el tipo de Hallazgo", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
Text("1. Tipo de Hallazgo", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
if (!options.catalog.typeConfigured) {
|
||||
Text(
|
||||
options.catalog.configurationReason
|
||||
?: "Este tipo de Inventario todavía no tiene un catálogo contextual configurado. Podés usar OTROS.",
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
"No hay tipos sugeridos para este Inventario. Elegí Otro para cargarlo manualmente.",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = search,
|
||||
onValueChange = { search = it },
|
||||
label = { Text("Buscar en catálogo") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
)
|
||||
|
||||
filtered.forEach { item ->
|
||||
val chosen = !other && selectedCatalogId == item.id
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().clickable {
|
||||
selectedCatalogId = item.id
|
||||
other = false
|
||||
severityText = item.suggestedSeverity?.toString().orEmpty()
|
||||
},
|
||||
colors = if (chosen) {
|
||||
CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer)
|
||||
} else {
|
||||
CardDefaults.cardColors()
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = catalogExpanded && !model.busy,
|
||||
onExpandedChange = {
|
||||
if (!model.busy) {
|
||||
catalogExpanded = it
|
||||
if (it) search = ""
|
||||
}
|
||||
},
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = if (catalogExpanded) search else when {
|
||||
other -> "Otro / No está en la lista"
|
||||
selected != null -> selected.title
|
||||
else -> ""
|
||||
},
|
||||
onValueChange = { search = it; catalogExpanded = true },
|
||||
label = { Text("Tipo de Hallazgo *") },
|
||||
placeholder = { Text("Buscar o seleccionar") },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = catalogExpanded) },
|
||||
modifier = Modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryEditable),
|
||||
enabled = !model.busy,
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||
keyboardActions = KeyboardActions(onNext = {
|
||||
catalogExpanded = false
|
||||
focusManager.moveFocus(FocusDirection.Down)
|
||||
}),
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = catalogExpanded && !model.busy,
|
||||
onDismissRequest = { catalogExpanded = false; search = "" },
|
||||
modifier = Modifier.heightIn(max = 320.dp),
|
||||
) {
|
||||
Column(Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
Text(if (chosen) "✓ ${item.title}" else item.title, fontWeight = FontWeight.SemiBold)
|
||||
Text(
|
||||
listOfNotNull(item.categoryName, item.code, item.suggestedSeverity?.let { "Gravedad sugerida $it" })
|
||||
.joinToString(" · "),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
filtered.forEach { item ->
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Column {
|
||||
Text(item.title, fontWeight = FontWeight.SemiBold)
|
||||
item.suggestedSeverity?.let {
|
||||
Text("Gravedad sugerida: $it/10", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
},
|
||||
onClick = {
|
||||
selectedCatalogId = item.id
|
||||
other = false
|
||||
severityText = item.suggestedSeverity?.toString().orEmpty()
|
||||
catalogExpanded = false
|
||||
search = ""
|
||||
keyboard?.hide()
|
||||
focusManager.moveFocus(FocusDirection.Down)
|
||||
},
|
||||
)
|
||||
}
|
||||
DropdownMenuItem(
|
||||
text = { Text("Otro / No está en la lista", fontWeight = FontWeight.SemiBold) },
|
||||
onClick = {
|
||||
other = true
|
||||
selectedCatalogId = null
|
||||
severityText = ""
|
||||
catalogExpanded = false
|
||||
search = ""
|
||||
keyboard?.hide()
|
||||
focusManager.moveFocus(FocusDirection.Down)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
other = true
|
||||
selectedCatalogId = null
|
||||
severityText = ""
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(if (other) "✓ OTROS · Hallazgo no catalogado" else "OTROS · No está en el catálogo")
|
||||
}
|
||||
if (other) {
|
||||
options.catalog.other.help?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
|
||||
Text("Cargá un nombre claro para identificar el Hallazgo.", style = MaterialTheme.typography.bodySmall)
|
||||
OutlinedTextField(
|
||||
value = customTitle,
|
||||
onValueChange = { customTitle = it },
|
||||
label = { Text("Título del nuevo Hallazgo *") },
|
||||
label = { Text("Nombre del Hallazgo *") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = customLegalBasis,
|
||||
onValueChange = { customLegalBasis = it },
|
||||
label = { Text("Base legal / normativa (opcional)") },
|
||||
label = { Text("Normativa (opcional)") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }),
|
||||
)
|
||||
}
|
||||
|
||||
Text("2. Describí lo observado", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
Text("2. Qué observaste", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
selected?.let {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
@@ -318,23 +367,21 @@ fun FieldFindingScreen(model: MainViewModel) {
|
||||
OutlinedTextField(
|
||||
value = description,
|
||||
onValueChange = { description = it },
|
||||
label = { Text("Descripción del Hallazgo *") },
|
||||
label = { Text("Qué observaste *") },
|
||||
placeholder = { Text("Describí brevemente el problema") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
minLines = 3,
|
||||
maxLines = 6,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = severityText,
|
||||
onValueChange = { value -> severityText = value.filter(Char::isDigit).take(2) },
|
||||
label = { Text("Gravedad 1 a 10") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
singleLine = true,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = correctionDueOn,
|
||||
onValueChange = { correctionDueOn = it },
|
||||
label = { Text("Fecha de corrección AAAA-MM-DD (opcional)") },
|
||||
label = { Text("Gravedad (1 a 10, opcional)") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(onDone = { keyboard?.hide(); focusManager.clearFocus() }),
|
||||
singleLine = true,
|
||||
)
|
||||
|
||||
@@ -348,7 +395,6 @@ fun FieldFindingScreen(model: MainViewModel) {
|
||||
customLegalBasis = if (other) customLegalBasis else null,
|
||||
description = description,
|
||||
severity = severity,
|
||||
correctionDueOn = correctionDueOn,
|
||||
)
|
||||
},
|
||||
enabled = choiceReady && description.isNotBlank() && (severity == null || severity in 1..10) && !model.busy,
|
||||
@@ -365,13 +411,19 @@ fun FieldFindingScreen(model: MainViewModel) {
|
||||
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text("Hallazgo registrado", fontWeight = FontWeight.Bold)
|
||||
Text("${finding.code} · ${finding.title}")
|
||||
val createdEvidence = model.fieldFindingEvidence[finding.id].orEmpty()
|
||||
val createdPhotoCount = createdEvidence.count { it.kind == "PHOTO" }
|
||||
Button(
|
||||
onClick = { requestPhoto(finding.id) },
|
||||
enabled = !model.busy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Tomar foto con GPS")
|
||||
Text(if (createdPhotoCount == 0) "Agregar foto con GPS" else "Agregar otra foto con GPS")
|
||||
}
|
||||
Text(
|
||||
if (createdPhotoCount == 0) "Podés adjuntar todas las fotos que necesites." else "Fotos adjuntas: $createdPhotoCount. Podés seguir agregando.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
Text("También podés registrar otro Hallazgo sobre el mismo Inventario.", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,26 +92,17 @@ fun MobileActsScreen(
|
||||
|
||||
LaunchedEffect(visit.id) { model.reloadActs() }
|
||||
|
||||
fun signWithGeo(file: File, company: Boolean) {
|
||||
fun signCompanyWithGeo(file: File) {
|
||||
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,
|
||||
)
|
||||
}
|
||||
model.signSelectedActAsCompany(
|
||||
png = file,
|
||||
latitude = geo?.latitude,
|
||||
longitude = geo?.longitude,
|
||||
accuracyM = geo?.accuracyM,
|
||||
manifestation = manifestation,
|
||||
statement = dissentStatement.takeIf { manifestation == "DISSENT" },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,28 +179,6 @@ fun MobileActsScreen(
|
||||
}
|
||||
|
||||
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(
|
||||
when (selected.urgency) {
|
||||
"URGENT" -> "Urgencia: Urgente"
|
||||
"NON_URGENT" -> "Urgencia: No urgente"
|
||||
else -> "Urgencia: se define al cerrar el Acta"
|
||||
},
|
||||
)
|
||||
Text("${selected.findingCount} Hallazgo${if (selected.findingCount == 1) "" else "s"} · ${selected.assetCount} elemento${if (selected.assetCount == 1) "" else "s"} de Inventario")
|
||||
selected.deadlineAt?.let { Text("Vencimiento calculado: $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) }
|
||||
}
|
||||
}
|
||||
|
||||
when (selected.status) {
|
||||
"DRAFT" -> {
|
||||
Card(
|
||||
@@ -263,7 +232,7 @@ fun MobileActsScreen(
|
||||
|
||||
HorizontalDivider()
|
||||
Text("Finalizar contenido", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
Text("Al cerrar el Acta, el contenido y los Hallazgos quedan inmutables. Definí ahora una única urgencia para el Acta completa; no para cada Hallazgo.")
|
||||
Text("Al cerrar el contenido, el Acta queda inmutable y pasa a Para firmar. La firma del inspector se toma de Mi perfil del Dashboard; en la tablet firma el acompañante de la empresa.")
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
AssistChip(
|
||||
onClick = { closingUrgency = "NON_URGENT" },
|
||||
@@ -278,50 +247,39 @@ fun MobileActsScreen(
|
||||
onClick = { model.prepareSelectedAct(closingUrgency) },
|
||||
enabled = !model.busy && closure?.responsible != null && closingUrgency.isNotBlank(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Finalizar y BLOQUEAR Acta") }
|
||||
) { Text("Cerrar contenido y pasar a firma") }
|
||||
}
|
||||
|
||||
"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"
|
||||
val companyResolved = companyOutcome?.status == "SIGNED" || companyOutcome?.status == "REFUSED" || companyOutcome?.status == "ABSENT"
|
||||
|
||||
Text("Acta BLOQUEADA", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
Text("El contenido ya es inmutable. Sólo resta resolver firmas y manifestaciones para poder SELLARLA.")
|
||||
closure?.closure?.preparedSha256?.let { Text("Hash bloqueado: $it", style = MaterialTheme.typography.bodySmall) }
|
||||
Text("Para firmar", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
Text("El contenido ya es inmutable. La firma del inspector está guardada en el Dashboard y se aplicará al cierre final.")
|
||||
|
||||
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("Manifestación de la empresa", fontWeight = FontWeight.Bold)
|
||||
Text("Acompañante / representante de la empresa", fontWeight = FontWeight.Bold)
|
||||
if (companyOutcome != null && companyResolved) {
|
||||
val detail = when (companyOutcome.status) {
|
||||
"SIGNED" -> if (companyOutcome.companyManifestation == "DISSENT") "Firma en disconformidad" else "Firma en conformidad"
|
||||
"REFUSED" -> "Negativa a firmar"
|
||||
"SIGNED" -> if (companyOutcome.companyManifestation == "DISSENT") "Firmó en disconformidad" else "Firmó en conformidad"
|
||||
"REFUSED" -> "Se negó a firmar"
|
||||
"ABSENT" -> "Ausencia documentada"
|
||||
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 representante fue registrado como ausente. La ausencia NO resuelve la manifestación: deberá obtenerse firma o negativa posteriormente antes de SELLAR el Acta.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
val recordedAbsence = closure.responsible.absenceReason.orEmpty()
|
||||
Text("El representante fue registrado como ausente. Confirmá esta ausencia para dejar la constancia documental.")
|
||||
Text(recordedAbsence, style = MaterialTheme.typography.bodySmall)
|
||||
OutlinedButton(
|
||||
onClick = { model.recordCompanyOutcome("ABSENT", recordedAbsence) },
|
||||
enabled = !model.busy && recordedAbsence.trim().length >= 10,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Confirmar ausencia documentada") }
|
||||
} 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 disconformidad" else "En disconformidad") })
|
||||
@@ -336,11 +294,10 @@ fun MobileActsScreen(
|
||||
)
|
||||
}
|
||||
SignaturePad(
|
||||
label = "Firma del representante de la empresa",
|
||||
enabled = !model.busy && inspectorSigned && (manifestation != "DISSENT" || dissentStatement.trim().length >= 10),
|
||||
onCaptured = { file -> signWithGeo(file, company = true) },
|
||||
label = "Firma del representante de la empresa (acompañante)",
|
||||
enabled = !model.busy && (manifestation != "DISSENT" || dissentStatement.trim().length >= 10),
|
||||
onCaptured = { file -> signCompanyWithGeo(file) },
|
||||
)
|
||||
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 },
|
||||
@@ -350,23 +307,20 @@ fun MobileActsScreen(
|
||||
)
|
||||
OutlinedButton(
|
||||
onClick = { model.recordCompanyOutcome("REFUSED", refusalReason) },
|
||||
enabled = !model.busy && inspectorSigned && refusalReason.trim().length >= 10,
|
||||
enabled = !model.busy && refusalReason.trim().length >= 10,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Registrar negativa a firmar") }
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
if (inspectorSigned && companyResolved) {
|
||||
if (companyResolved) {
|
||||
Button(
|
||||
onClick = { model.closeSelectedAct() },
|
||||
enabled = !model.busy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("SELLAR Acta definitivamente") }
|
||||
) { Text("Aplicar mi firma y cerrar Acta") }
|
||||
} else {
|
||||
Text(
|
||||
"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,
|
||||
)
|
||||
Text("Completá la firma, disconformidad o negativa del acompañante para habilitar el cierre final.", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ 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.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
@@ -86,21 +85,33 @@ fun MobileHomeScreen(model: MainViewModel) {
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxSize().padding(top = 28.dp)) {
|
||||
Row(
|
||||
Column(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
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 && !opening) {
|
||||
Text("Actualizar")
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = { model.loadVisits() },
|
||||
enabled = !model.busy && !opening,
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text("Actualizar", maxLines = 1)
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = { model.logout() },
|
||||
enabled = !opening,
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text("Salir", maxLines = 1)
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
OutlinedButton(onClick = { model.logout() }, enabled = !opening) { Text("Salir") }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+62
-92
@@ -3,7 +3,6 @@ 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
|
||||
@@ -91,26 +90,17 @@ fun ModernMobileActsScreen(
|
||||
|
||||
LaunchedEffect(visit.id) { model.reloadActs() }
|
||||
|
||||
fun signWithGeo(file: File, company: Boolean) {
|
||||
fun signCompanyWithGeo(file: File) {
|
||||
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,
|
||||
)
|
||||
}
|
||||
model.signSelectedActAsCompany(
|
||||
png = file,
|
||||
latitude = geo?.latitude,
|
||||
longitude = geo?.longitude,
|
||||
accuracyM = geo?.accuracyM,
|
||||
manifestation = manifestation,
|
||||
statement = dissentStatement.takeIf { manifestation == "DISSENT" },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,12 +162,8 @@ fun ModernMobileActsScreen(
|
||||
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,
|
||||
),
|
||||
color = if (active) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant,
|
||||
tonalElevation = 0.dp,
|
||||
) {
|
||||
Row(
|
||||
Modifier.padding(15.dp),
|
||||
@@ -245,33 +231,6 @@ fun ModernMobileActsScreen(
|
||||
}
|
||||
|
||||
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(
|
||||
when (selected.urgency) {
|
||||
"URGENT" -> "Urgencia · Urgente"
|
||||
"NON_URGENT" -> "Urgencia · No urgente"
|
||||
else -> "Urgencia · Se define al cerrar el Acta"
|
||||
},
|
||||
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()) {
|
||||
@@ -338,7 +297,7 @@ fun ModernMobileActsScreen(
|
||||
Text("Cerrar Acta", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
Text(
|
||||
"Al cerrar el contenido, el Acta queda inmutable y pasa a Pendiente de firma. En este momento definí la urgencia del Acta completa. La urgencia no se asigna a cada Hallazgo.",
|
||||
"Al cerrar el contenido, el Acta queda inmutable y pasa a Para firmar. La firma del inspector se toma de Mi perfil del Dashboard; en la tablet sólo firma el acompañante de la empresa. La urgencia pertenece al Acta completa. La urgencia no se asigna a cada Hallazgo.",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text("Urgencia del Acta", fontWeight = FontWeight.SemiBold)
|
||||
@@ -367,64 +326,75 @@ fun ModernMobileActsScreen(
|
||||
onClick = { model.prepareSelectedAct(closingUrgency) },
|
||||
enabled = !model.busy && closure?.responsible != null && closingUrgency.isNotBlank(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Cerrar Acta y dejar pendiente de firma") }
|
||||
) { Text("Cerrar contenido y pasar a firma") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
"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"
|
||||
val companyResolved = companyOutcome?.status == "SIGNED" || companyOutcome?.status == "REFUSED" || companyOutcome?.status == "ABSENT"
|
||||
|
||||
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)
|
||||
Icon(Icons.Filled.EditNote, null, tint = MaterialTheme.colorScheme.primary)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Acta pendiente de firma", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
Text("Para firmar", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
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)
|
||||
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) },
|
||||
)
|
||||
Text(
|
||||
"El contenido ya está cerrado e inmutable. Ahora corresponde la manifestación y firma del acompañante de la empresa.",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
) {
|
||||
Row(Modifier.padding(12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Icon(Icons.Filled.CheckCircle, null, tint = MaterialTheme.colorScheme.primary)
|
||||
Text(
|
||||
"Tu firma de inspector está guardada en el Dashboard y se incorporará automáticamente cuando cierres esta Acta.",
|
||||
Modifier.weight(1f),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
Text("Manifestación de la empresa", fontWeight = FontWeight.Bold)
|
||||
Text("Acompañante / representante de la empresa", fontWeight = FontWeight.Bold)
|
||||
if (companyOutcome != null && companyResolved) {
|
||||
val detail = when (companyOutcome.status) {
|
||||
"SIGNED" -> if (companyOutcome.companyManifestation == "DISSENT") "Firma en disconformidad" else "Firma en conformidad"
|
||||
"REFUSED" -> "Negativa a firmar"
|
||||
"SIGNED" -> if (companyOutcome.companyManifestation == "DISSENT") "Firmó en disconformidad" else "Firmó en conformidad"
|
||||
"REFUSED" -> "Se negó a firmar"
|
||||
"ABSENT" -> "Ausencia documentada"
|
||||
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") {
|
||||
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 firmar y cerrar el Acta.",
|
||||
Modifier.weight(1f),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
val recordedAbsence = closure.responsible.absenceReason.orEmpty()
|
||||
Surface(Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.secondaryContainer) {
|
||||
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Icon(Icons.Filled.ErrorOutline, null, tint = MaterialTheme.colorScheme.secondary)
|
||||
Text(
|
||||
"El representante fue registrado como ausente. Confirmá esta ausencia para dejar la constancia documental.",
|
||||
Modifier.weight(1f),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
Text(recordedAbsence, style = MaterialTheme.typography.bodySmall)
|
||||
OutlinedButton(
|
||||
onClick = { model.recordCompanyOutcome("ABSENT", recordedAbsence) },
|
||||
enabled = !model.busy && recordedAbsence.trim().length >= 10,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Confirmar ausencia documentada") }
|
||||
}
|
||||
}
|
||||
} 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 disconformidad") })
|
||||
@@ -439,11 +409,11 @@ fun ModernMobileActsScreen(
|
||||
)
|
||||
}
|
||||
SignaturePad(
|
||||
label = "Firma del representante de la empresa",
|
||||
enabled = !model.busy && inspectorSigned && (manifestation != "DISSENT" || dissentStatement.trim().length >= 10),
|
||||
onCaptured = { file -> signWithGeo(file, company = true) },
|
||||
label = "Firma del representante de la empresa (acompañante)",
|
||||
enabled = !model.busy && (manifestation != "DISSENT" || dissentStatement.trim().length >= 10),
|
||||
onCaptured = { file -> signCompanyWithGeo(file) },
|
||||
)
|
||||
Text("Si se niega a firmar, registrá el motivo.", style = MaterialTheme.typography.bodySmall)
|
||||
Text("Si se niega a firmar, asentá el motivo para dejar la constancia.", style = MaterialTheme.typography.bodySmall)
|
||||
OutlinedTextField(
|
||||
refusalReason,
|
||||
{ refusalReason = it },
|
||||
@@ -453,12 +423,12 @@ fun ModernMobileActsScreen(
|
||||
)
|
||||
OutlinedButton(
|
||||
onClick = { model.recordCompanyOutcome("REFUSED", refusalReason) },
|
||||
enabled = !model.busy && inspectorSigned && refusalReason.trim().length >= 10,
|
||||
enabled = !model.busy && refusalReason.trim().length >= 10,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Registrar negativa a firmar") }
|
||||
}
|
||||
|
||||
if (inspectorSigned && companyResolved) {
|
||||
if (companyResolved) {
|
||||
Button(
|
||||
onClick = { model.closeSelectedAct() },
|
||||
enabled = !model.busy,
|
||||
@@ -466,11 +436,11 @@ fun ModernMobileActsScreen(
|
||||
) {
|
||||
Icon(Icons.Filled.CheckCircle, null)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Firmar y cerrar Acta")
|
||||
Text("Aplicar mi firma y cerrar Acta")
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
"La Inspección no puede cerrarse hasta completar las manifestaciones de esta Acta.",
|
||||
"Completá la firma, disconformidad o negativa del acompañante para habilitar el cierre final.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
@@ -63,6 +63,7 @@ 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.focus.FocusDirection
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
@@ -778,6 +779,11 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
|
||||
enabled = !model.busy,
|
||||
modifier = Modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryEditable),
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||
keyboardActions = KeyboardActions(onNext = {
|
||||
familyExpanded = false
|
||||
focusManager.moveFocus(FocusDirection.Down)
|
||||
}),
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = familyExpanded && !model.busy,
|
||||
@@ -818,9 +824,11 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text("Nombre técnico *") },
|
||||
supportingText = { Text("Usá la denominación técnica del elemento. El código DH se genera automáticamente.") },
|
||||
placeholder = { Text("Denominación técnica") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = commonName,
|
||||
@@ -828,17 +836,24 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
|
||||
label = { Text("Nombre habitual (opcional)") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = description,
|
||||
onValueChange = { description = it },
|
||||
label = { Text("Descripción (opcional)") },
|
||||
supportingText = { Text("Dato equivalente al alta desde el panel web.") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
minLines = 2,
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(imeAction = if (editableAttributes.isEmpty()) ImeAction.Done else ImeAction.Next),
|
||||
keyboardActions = KeyboardActions(
|
||||
onNext = { focusManager.moveFocus(FocusDirection.Down) },
|
||||
onDone = { keyboard?.hide(); focusManager.clearFocus() },
|
||||
),
|
||||
)
|
||||
|
||||
editableAttributes.forEach { definition ->
|
||||
editableAttributes.forEachIndexed { index, definition ->
|
||||
val isLastAttribute = index == editableAttributes.lastIndex
|
||||
OutlinedTextField(
|
||||
value = attributeValues[definition.code].orEmpty(),
|
||||
onValueChange = { attributeValues[definition.code] = it },
|
||||
@@ -849,8 +864,14 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = if (definition.dataType.uppercase() in setOf("NUMBER", "DECIMAL", "INTEGER", "FLOAT")) KeyboardType.Decimal else KeyboardType.Text,
|
||||
imeAction = if (isLastAttribute) ImeAction.Done else ImeAction.Next,
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onNext = { focusManager.moveFocus(FocusDirection.Down) },
|
||||
onDone = { keyboard?.hide(); focusManager.clearFocus() },
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ internal fun visitStatusLabelEs(status: String?): String = when (status?.upperca
|
||||
|
||||
internal fun actStatusLabelEs(status: String?): String = when (status?.uppercase(Locale.ROOT)) {
|
||||
"DRAFT" -> "En elaboración"
|
||||
"LOCKED" -> "Pendiente de firma"
|
||||
"LOCKED" -> "Para firmar"
|
||||
"SEALED" -> "Firmada y cerrada"
|
||||
"CLOSED" -> "Cerrada"
|
||||
"RECTIFIED" -> "Rectificada"
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 8.2 KiB |
+13
-3
@@ -20,15 +20,15 @@ class FinalFieldFlowContractTest {
|
||||
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(acts.contains("Cerrar contenido y pasar a firma"))
|
||||
assertTrue(acts.contains("Nombres y apellidos *"))
|
||||
assertTrue(acts.contains("Cargo / función *"))
|
||||
assertTrue(acts.contains("Email *"))
|
||||
assertTrue(acts.contains("En disconformidad"))
|
||||
assertTrue(acts.contains("Motivo de disconformidad *"))
|
||||
assertTrue(acts.contains("urgencia del Acta completa"))
|
||||
assertTrue(acts.contains("urgencia pertenece al Acta completa"))
|
||||
assertTrue(acts.contains("urgencia no se asigna a cada Hallazgo"))
|
||||
assertTrue(labels.contains("\"LOCKED\" -> \"Pendiente de firma\""))
|
||||
assertTrue(labels.contains("\"LOCKED\" -> \"Para firmar\""))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -63,4 +63,14 @@ class FinalFieldFlowContractTest {
|
||||
assertFalse(acts.contains("Acta SELLADA"))
|
||||
assertFalse(acts.contains("Text(\"Email\")"))
|
||||
}
|
||||
@Test
|
||||
fun findingCreationIsCompactSearchableAndHasNoCorrectionDate() {
|
||||
assertTrue(findings.contains("ExposedDropdownMenuBox"))
|
||||
assertTrue(findings.contains("Tipo de Hallazgo *"))
|
||||
assertTrue(findings.contains("Buscar o seleccionar"))
|
||||
assertTrue(findings.contains("Otro / No está en la lista"))
|
||||
assertFalse(findings.contains("Fecha de corrección"))
|
||||
assertFalse(findings.contains("Hallazgos del modelo autoritativo"))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ class ReleaseMetadataTest {
|
||||
@Test
|
||||
fun debugBuildKeepsSeparateApplicationIdentity() {
|
||||
assertEquals("com.korexlabs.dhinspeccion.debug", BuildConfig.APPLICATION_ID)
|
||||
assertEquals(33, BuildConfig.VERSION_CODE)
|
||||
assertEquals("0.19.5-debug", BuildConfig.VERSION_NAME)
|
||||
assertEquals(37, BuildConfig.VERSION_CODE)
|
||||
assertEquals("0.19.9-debug", BuildConfig.VERSION_NAME)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "dhv2-api",
|
||||
"version": "0.29.0-4",
|
||||
"version": "0.29.0-7",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "dhv2-api",
|
||||
"version": "0.29.0-4",
|
||||
"version": "0.29.0-7",
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^11.0.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dhv2-api",
|
||||
"version": "0.29.0-4",
|
||||
"version": "0.29.0-7",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
@@ -9,7 +10,12 @@ import {
|
||||
Put,
|
||||
Query,
|
||||
Req,
|
||||
Res,
|
||||
UploadedFile,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import type { Response } from 'express';
|
||||
import { AuditService } from '../../audit/audit.service';
|
||||
import { RequirePermissions } from '../../authorization/decorators/require-permissions.decorator';
|
||||
import { CurrentAuth } from '../../auth/decorators/current-auth.decorator';
|
||||
@@ -19,6 +25,10 @@ import type {
|
||||
} from '../../common/http/request-context';
|
||||
import { AuditAction } from '../../database/entities';
|
||||
import { SmtpDeliveryService } from '../../inspection-reports/smtp-delivery.service';
|
||||
import {
|
||||
MAX_INSPECTION_SIGNATURE_BYTES,
|
||||
type UploadedInspectionSignatureFile,
|
||||
} from '../../inspection-closing/inspection-signature-file';
|
||||
import { administrationAuditContext } from '../common/administration-audit';
|
||||
import { ChangeUserStatusDto } from './dto/change-user-status.dto';
|
||||
import { CreateUserDto } from './dto/create-user.dto';
|
||||
@@ -58,6 +68,45 @@ export class UsersController {
|
||||
return this.users.updateSelfProfile(dto, principal, request);
|
||||
}
|
||||
|
||||
@Get('self/signature')
|
||||
selfSignature(@CurrentAuth() principal: AuthPrincipal) {
|
||||
return this.users.getSelfSignature(principal.userId);
|
||||
}
|
||||
|
||||
@Get('self/signature/content')
|
||||
async selfSignatureContent(
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Res() response: Response,
|
||||
): Promise<void> {
|
||||
const signature = await this.users.getSelfSignatureContent(principal.userId);
|
||||
response.setHeader('Content-Type', 'image/png');
|
||||
response.setHeader('Content-Length', String(signature.buffer.length));
|
||||
response.setHeader('Cache-Control', 'private, no-store');
|
||||
response.setHeader('ETag', `"${signature.sha256}"`);
|
||||
response.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
response.send(signature.buffer);
|
||||
}
|
||||
|
||||
@Put('self/signature')
|
||||
@UseInterceptors(FileInterceptor('file', {
|
||||
limits: { fileSize: MAX_INSPECTION_SIGNATURE_BYTES, files: 1 },
|
||||
}))
|
||||
updateSelfSignature(
|
||||
@UploadedFile() file: UploadedInspectionSignatureFile | undefined,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.users.updateSelfSignature(file, principal, request);
|
||||
}
|
||||
|
||||
@Delete('self/signature')
|
||||
deleteSelfSignature(
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.users.deleteSelfSignature(principal, request);
|
||||
}
|
||||
|
||||
@Get('self/smtp')
|
||||
selfSmtp(@CurrentAuth() principal: AuthPrincipal) {
|
||||
return this.smtp.publicUserSettings(principal.userId);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
@@ -31,6 +33,10 @@ import type { ReplaceUserRolesDto } from './dto/replace-user-roles.dto';
|
||||
import type { ResetUserPasswordDto } from './dto/reset-user-password.dto';
|
||||
import type { UpdateSelfProfileDto } from './dto/update-self-profile.dto';
|
||||
import type { UpdateUserDto } from './dto/update-user.dto';
|
||||
import {
|
||||
inspectInspectionSignatureFile,
|
||||
type UploadedInspectionSignatureFile,
|
||||
} from '../../inspection-closing/inspection-signature-file';
|
||||
|
||||
export interface UserRoleView {
|
||||
id: string;
|
||||
@@ -38,6 +44,14 @@ export interface UserRoleView {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface UserReusableSignatureView {
|
||||
configured: boolean;
|
||||
mimeType: 'image/png' | null;
|
||||
sizeBytes: number | null;
|
||||
imageSha256: string | null;
|
||||
updatedAt: Date | null;
|
||||
}
|
||||
|
||||
export interface AdministrativeUserView {
|
||||
id: string;
|
||||
username: string;
|
||||
@@ -192,6 +206,87 @@ export class UsersService {
|
||||
return this.getById(userId);
|
||||
}
|
||||
|
||||
async getSelfSignature(userId: string): Promise<UserReusableSignatureView> {
|
||||
const [row] = (await this.dataSource.query(`
|
||||
SELECT mime_type AS "mimeType", size_bytes AS "sizeBytes",
|
||||
image_sha256 AS "imageSha256", updated_at AS "updatedAt"
|
||||
FROM user_signature_profiles WHERE user_id=$1
|
||||
`, [userId])) as Array<{
|
||||
mimeType: 'image/png'; sizeBytes: number; imageSha256: string; updatedAt: Date;
|
||||
}>;
|
||||
return row ? { configured: true, ...row } : {
|
||||
configured: false, mimeType: null, sizeBytes: null, imageSha256: null, updatedAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
async getSelfSignatureContent(userId: string): Promise<{ buffer: Buffer; sha256: string }> {
|
||||
const [row] = (await this.dataSource.query(`
|
||||
SELECT image_data AS buffer, image_sha256 AS sha256
|
||||
FROM user_signature_profiles WHERE user_id=$1
|
||||
`, [userId])) as Array<{ buffer: Buffer; sha256: string }>;
|
||||
if (!row) throw new NotFoundException({
|
||||
code: 'USER_SIGNATURE_NOT_CONFIGURED',
|
||||
message: 'Todavía no cargaste tu firma de inspector',
|
||||
});
|
||||
return row;
|
||||
}
|
||||
|
||||
async updateSelfSignature(
|
||||
file: UploadedInspectionSignatureFile | undefined,
|
||||
principal: AuthPrincipal,
|
||||
request: RequestWithContext,
|
||||
): Promise<UserReusableSignatureView> {
|
||||
if (!principal.roles.includes('inspector')) throw new ForbiddenException({
|
||||
code: 'INSPECTOR_SIGNATURE_ROLE_REQUIRED',
|
||||
message: 'La firma reutilizable está disponible para usuarios con rol Inspector',
|
||||
});
|
||||
const inspected = inspectInspectionSignatureFile(file);
|
||||
const imageSha256 = createHash('sha256').update(file!.buffer).digest('hex');
|
||||
const before = await this.getSelfSignature(principal.userId);
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.query(`
|
||||
INSERT INTO user_signature_profiles (
|
||||
user_id,original_name,mime_type,size_bytes,image_sha256,image_data,updated_by
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$1)
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
original_name=EXCLUDED.original_name, mime_type=EXCLUDED.mime_type,
|
||||
size_bytes=EXCLUDED.size_bytes, image_sha256=EXCLUDED.image_sha256,
|
||||
image_data=EXCLUDED.image_data, updated_by=EXCLUDED.updated_by,
|
||||
updated_at=CURRENT_TIMESTAMP
|
||||
`, [principal.userId, inspected.originalName, inspected.mimeType, file!.buffer.length, imageSha256, file!.buffer]);
|
||||
await this.audit.record({
|
||||
...administrationAuditContext(principal, request),
|
||||
action: AuditAction.USER_UPDATED,
|
||||
entityType: 'user_signature_profile',
|
||||
entityId: principal.userId,
|
||||
beforeData: before as unknown as Record<string, unknown>,
|
||||
afterData: { configured: true, imageSha256, sizeBytes: file!.buffer.length },
|
||||
metadata: { scope: 'SELF_SIGNATURE', reusableForActs: true },
|
||||
}, manager);
|
||||
});
|
||||
return this.getSelfSignature(principal.userId);
|
||||
}
|
||||
|
||||
async deleteSelfSignature(
|
||||
principal: AuthPrincipal,
|
||||
request: RequestWithContext,
|
||||
): Promise<UserReusableSignatureView> {
|
||||
const before = await this.getSelfSignature(principal.userId);
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.query('DELETE FROM user_signature_profiles WHERE user_id=$1', [principal.userId]);
|
||||
await this.audit.record({
|
||||
...administrationAuditContext(principal, request),
|
||||
action: AuditAction.USER_UPDATED,
|
||||
entityType: 'user_signature_profile',
|
||||
entityId: principal.userId,
|
||||
beforeData: before as unknown as Record<string, unknown>,
|
||||
afterData: { configured: false },
|
||||
metadata: { scope: 'SELF_SIGNATURE', reusableForActs: true },
|
||||
}, manager);
|
||||
});
|
||||
return this.getSelfSignature(principal.userId);
|
||||
}
|
||||
|
||||
async updateSelfProfile(
|
||||
dto: UpdateSelfProfileDto,
|
||||
principal: AuthPrincipal,
|
||||
|
||||
@@ -760,6 +760,7 @@ export class AssetsService {
|
||||
parentId: dto.parentId,
|
||||
operationalAreaId: dto.operationalAreaId,
|
||||
operatorCompanyId: dto.operatorCompanyId,
|
||||
inventoryFamilyId: dto.inventoryFamilyId ?? null,
|
||||
code: dto.code,
|
||||
name: dto.name,
|
||||
commonName: dto.commonName ?? null,
|
||||
|
||||
@@ -36,6 +36,10 @@ export class CreateFieldDiscoveryDto {
|
||||
@IsUUID('4')
|
||||
operatorCompanyId!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
inventoryFamilyId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null)
|
||||
@IsString()
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* F4 cambió el código visible del Acta a ACT-NNNNN-DD-MM-YY, pero el CHECK
|
||||
* histórico de Hallazgos seguía aceptando sólo ACTA-YYYY-NNNNNN-HNNN.
|
||||
* El servicio genera el Hallazgo a partir del código real del Acta, por lo que
|
||||
* PostgreSQL rechazaba altas válidas de campo con un 500.
|
||||
*/
|
||||
export class F65FieldFindingCodeContract1790121000000 implements MigrationInterface {
|
||||
name = 'F65FieldFindingCodeContract1790121000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE inspection_findings
|
||||
DROP CONSTRAINT IF EXISTS chk_inspection_findings_code
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE inspection_findings
|
||||
ADD CONSTRAINT chk_inspection_findings_code CHECK (
|
||||
code ~ '^(ACTA-[0-9]{4}-[0-9]{6}|ACT-[0-9]{5}-[0-9]{2}-[0-9]{2}-[0-9]{2})-H[0-9]{3}$'
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
UPDATE inspection_findings finding
|
||||
SET code = 'ACTA-' || act.act_year::text || '-' || LPAD(act.act_number::text, 6, '0')
|
||||
|| '-H' || LPAD(finding.finding_number::text, 3, '0')
|
||||
FROM inspection_acts act
|
||||
WHERE finding.act_id = act.id
|
||||
AND finding.code ~ '^ACT-[0-9]{5}-[0-9]{2}-[0-9]{2}-[0-9]{2}-H[0-9]{3}$'
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE inspection_findings
|
||||
DROP CONSTRAINT IF EXISTS chk_inspection_findings_code
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE inspection_findings
|
||||
ADD CONSTRAINT chk_inspection_findings_code CHECK (
|
||||
code ~ '^ACTA-[0-9]{4}-[0-9]{6}-H[0-9]{3}$'
|
||||
)
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
function quoteIdentifier(identifier: string): string {
|
||||
return `"${identifier.replaceAll('"', '""')}"`;
|
||||
}
|
||||
|
||||
export class F67UserReusableSignature1790124600000 implements MigrationInterface {
|
||||
name = 'F67UserReusableSignature1790124600000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE user_signature_profiles (
|
||||
user_id uuid PRIMARY KEY,
|
||||
original_name varchar(255) NOT NULL,
|
||||
mime_type varchar(100) NOT NULL,
|
||||
size_bytes integer NOT NULL,
|
||||
image_sha256 char(64) NOT NULL,
|
||||
image_data bytea NOT NULL,
|
||||
updated_by uuid,
|
||||
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT chk_user_signature_profiles_png CHECK (mime_type='image/png'),
|
||||
CONSTRAINT chk_user_signature_profiles_size CHECK (
|
||||
size_bytes BETWEEN 1 AND 1048576
|
||||
AND OCTET_LENGTH(image_data)=size_bytes
|
||||
),
|
||||
CONSTRAINT chk_user_signature_profiles_sha CHECK (
|
||||
image_sha256 ~ '^[0-9a-f]{64}$'
|
||||
),
|
||||
CONSTRAINT fk_user_signature_profiles_user FOREIGN KEY (user_id)
|
||||
REFERENCES users(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_user_signature_profiles_updated_by FOREIGN KEY (updated_by)
|
||||
REFERENCES users(id) ON DELETE SET NULL
|
||||
)
|
||||
`);
|
||||
const appRole = process.env.DB_APP_USER;
|
||||
if (!appRole) throw new Error('Missing required environment variable: DB_APP_USER');
|
||||
const applicationRole = quoteIdentifier(appRole);
|
||||
await queryRunner.query(`
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE
|
||||
ON TABLE user_signature_profiles
|
||||
TO ${applicationRole}
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query('DROP TABLE user_signature_profiles');
|
||||
}
|
||||
}
|
||||
@@ -103,6 +103,14 @@ interface StoredSignature extends SignatureView {
|
||||
storedName: string | null;
|
||||
}
|
||||
|
||||
interface ReusableInspectorSignature {
|
||||
originalName: string;
|
||||
mimeType: 'image/png';
|
||||
sizeBytes: number;
|
||||
imageSha256: string;
|
||||
imageData: Buffer;
|
||||
}
|
||||
|
||||
interface ClosureRecord {
|
||||
actId: string;
|
||||
schemaVersion: string;
|
||||
@@ -306,21 +314,24 @@ export class InspectionClosingService {
|
||||
const lockedAt = new Date();
|
||||
|
||||
const [updated] = (await manager.query(`
|
||||
UPDATE inspection_acts
|
||||
SET status='LOCKED',
|
||||
urgency=$2,
|
||||
deadline_days=$3,
|
||||
deadline_day_type=$4,
|
||||
deadline_basis=$5,
|
||||
deadline_base_at=$6,
|
||||
deadline_at=$7,
|
||||
locked_at=$8,
|
||||
locked_by=$9,
|
||||
current_version=current_version+1,
|
||||
updated_by=$9,
|
||||
updated_at=$8
|
||||
WHERE id=$1
|
||||
RETURNING current_version AS "versionNumber"
|
||||
WITH updated AS (
|
||||
UPDATE inspection_acts
|
||||
SET status='LOCKED',
|
||||
urgency=$2,
|
||||
deadline_days=$3,
|
||||
deadline_day_type=$4,
|
||||
deadline_basis=$5,
|
||||
deadline_base_at=$6,
|
||||
deadline_at=$7,
|
||||
locked_at=$8,
|
||||
locked_by=$9,
|
||||
current_version=current_version+1,
|
||||
updated_by=$9,
|
||||
updated_at=$8
|
||||
WHERE id=$1
|
||||
RETURNING current_version
|
||||
)
|
||||
SELECT current_version AS "versionNumber" FROM updated
|
||||
`, [
|
||||
actId,
|
||||
dto.urgency,
|
||||
@@ -377,7 +388,7 @@ export class InspectionClosingService {
|
||||
afterData: {
|
||||
status: InspectionActStatus.LOCKED,
|
||||
lockedSha256: preparedSha256,
|
||||
urgency: act.urgency,
|
||||
urgency: dto.urgency,
|
||||
deadlineDays,
|
||||
deadlineDayType,
|
||||
deadlineBasis,
|
||||
@@ -461,6 +472,13 @@ export class InspectionClosingService {
|
||||
message: 'La negativa a firmar requiere identificar al responsable que se negó',
|
||||
});
|
||||
}
|
||||
if (dto.status === InspectionActSignatureStatus.ABSENT
|
||||
&& responsible.attendanceStatus !== InspectionResponsibleAttendanceStatus.ABSENT) {
|
||||
throw new BadRequestException({
|
||||
code: 'INSPECTION_COMPANY_ABSENCE_REQUIRES_ABSENT_RESPONSIBLE',
|
||||
message: 'La ausencia sólo puede confirmarse cuando el representante fue registrado como ausente',
|
||||
});
|
||||
}
|
||||
if (await this.hasCompanyOutcome(manager, actId)) {
|
||||
throw new ConflictException({
|
||||
code: 'INSPECTION_ACT_COMPANY_OUTCOME_ALREADY_RECORDED',
|
||||
@@ -532,22 +550,15 @@ export class InspectionClosingService {
|
||||
request: RequestWithContext,
|
||||
): Promise<InspectionClosureView> {
|
||||
assertMobileInspector(principal);
|
||||
const sealed = await this.dataSource.transaction(async (manager) => {
|
||||
let generatedInspectorSignaturePath: string | null = null;
|
||||
let sealed: InspectionClosureView;
|
||||
try {
|
||||
sealed = await this.dataSource.transaction(async (manager) => {
|
||||
const { act, visit } = await this.lockContext(manager, actId);
|
||||
this.assertLockedForManifestation(act);
|
||||
await this.assertActorAssigned(manager, visit.id, principal, true);
|
||||
const closure = await this.requireClosure(manager, actId);
|
||||
const signatures = await this.loadSignatures(manager, actId);
|
||||
const inspectorSigned = signatures.some((item) =>
|
||||
item.signerType === InspectionActSignerType.INSPECTOR
|
||||
&& item.status === InspectionActSignatureStatus.SIGNED,
|
||||
);
|
||||
if (!inspectorSigned) {
|
||||
throw new ConflictException({
|
||||
code: 'INSPECTION_ACT_INSPECTOR_SIGNATURE_REQUIRED',
|
||||
message: 'Se requiere la firma del inspector para sellar el acta',
|
||||
});
|
||||
}
|
||||
let signatures = await this.loadSignatures(manager, actId);
|
||||
const companyOutcomes = signatures.filter((item) =>
|
||||
item.signerType === InspectionActSignerType.COMPANY_RESPONSIBLE,
|
||||
);
|
||||
@@ -557,6 +568,17 @@ export class InspectionClosingService {
|
||||
message: 'Debe existir exactamente una firma, disidencia, negativa o ausencia documentada del responsable de la empresa',
|
||||
});
|
||||
}
|
||||
const inspectorSigned = signatures.some((item) =>
|
||||
item.signerType === InspectionActSignerType.INSPECTOR
|
||||
&& item.status === InspectionActSignatureStatus.SIGNED,
|
||||
);
|
||||
if (!inspectorSigned) {
|
||||
const created = await this.createInspectorSignatureFromProfile(
|
||||
manager, actId, closure.preparedSha256, principal, request,
|
||||
);
|
||||
generatedInspectorSignaturePath = created.filePath;
|
||||
signatures = await this.loadSignatures(manager, actId);
|
||||
}
|
||||
|
||||
const serverSealedAt = new Date();
|
||||
const deviceSealedAt = new Date(dto.clientClosedAt);
|
||||
@@ -619,18 +641,21 @@ export class InspectionClosingService {
|
||||
WHERE act_id=$1
|
||||
`, [actId, finalSnapshot, finalSha256, deviceSealedAt, serverSealedAt, dto.uploadMode, principal.userId]);
|
||||
const [updated] = (await manager.query(`
|
||||
UPDATE inspection_acts
|
||||
SET status='SEALED',
|
||||
sealed_at=$2,
|
||||
sealed_by=$3,
|
||||
closed_at=$2,
|
||||
closed_by=$3,
|
||||
closure_sha256=$4,
|
||||
current_version=current_version+1,
|
||||
updated_by=$3,
|
||||
updated_at=$2
|
||||
WHERE id=$1
|
||||
RETURNING current_version AS "versionNumber"
|
||||
WITH updated AS (
|
||||
UPDATE inspection_acts
|
||||
SET status='SEALED',
|
||||
sealed_at=$2,
|
||||
sealed_by=$3,
|
||||
closed_at=$2,
|
||||
closed_by=$3,
|
||||
closure_sha256=$4,
|
||||
current_version=current_version+1,
|
||||
updated_by=$3,
|
||||
updated_at=$2
|
||||
WHERE id=$1
|
||||
RETURNING current_version
|
||||
)
|
||||
SELECT current_version AS "versionNumber" FROM updated
|
||||
`, [actId, serverSealedAt, principal.userId, finalSha256])) as Array<{ versionNumber: number }>;
|
||||
await manager.query(`
|
||||
INSERT INTO inspection_act_versions (
|
||||
@@ -664,7 +689,11 @@ export class InspectionClosingService {
|
||||
}, manager);
|
||||
await this.reports.ensureFrozenReport(manager, actId, principal, request);
|
||||
return this.loadView(manager, actId);
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
if (generatedInspectorSignaturePath) await unlink(generatedInspectorSignaturePath).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
await this.reports.ensureWordForAct(actId);
|
||||
return sealed;
|
||||
}
|
||||
@@ -1171,6 +1200,82 @@ export class InspectionClosingService {
|
||||
return rows.map(({ storedName: _storedName, originalName: _originalName, ...row }) => row);
|
||||
}
|
||||
|
||||
private async requireReusableInspectorSignature(
|
||||
manager: EntityManager,
|
||||
userId: string,
|
||||
): Promise<ReusableInspectorSignature> {
|
||||
const [row] = await manager.query(`
|
||||
SELECT original_name AS "originalName", mime_type AS "mimeType",
|
||||
size_bytes AS "sizeBytes", image_sha256 AS "imageSha256",
|
||||
image_data AS "imageData"
|
||||
FROM user_signature_profiles
|
||||
WHERE user_id=$1
|
||||
`, [userId]) as ReusableInspectorSignature[];
|
||||
if (!row) {
|
||||
throw new ConflictException({
|
||||
code: 'INSPECTION_INSPECTOR_PROFILE_SIGNATURE_REQUIRED',
|
||||
message: 'Para cerrar definitivamente el Acta, cargá tu firma de inspector desde Mi perfil en el Dashboard',
|
||||
});
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
private async createInspectorSignatureFromProfile(
|
||||
manager: EntityManager,
|
||||
actId: string,
|
||||
preparedSha256: string,
|
||||
principal: AuthPrincipal,
|
||||
request: RequestWithContext,
|
||||
): Promise<{ filePath: string }> {
|
||||
const profile = await this.requireReusableInspectorSignature(manager, principal.userId);
|
||||
const id = randomUUID();
|
||||
const storedName = `${id}.png`;
|
||||
const filePath = resolve(this.signatureRoot, storedName);
|
||||
await mkdir(this.signatureRoot, { recursive: true, mode: 0o700 });
|
||||
await writeFile(filePath, profile.imageData, { flag: 'wx', mode: 0o600 });
|
||||
const signedAt = new Date();
|
||||
const source = this.signatureSource(principal);
|
||||
const signerName = `${principal.firstName} ${principal.lastName}`.trim();
|
||||
const payload = {
|
||||
actId, lockedSha256: preparedSha256, signerType: InspectionActSignerType.INSPECTOR,
|
||||
signerUserId: principal.userId, signerName, position: 'Inspector/a',
|
||||
status: InspectionActSignatureStatus.SIGNED, imageSha256: profile.imageSha256,
|
||||
consentText: INSPECTOR_CONSENT, consentVersion: CONSENT_VERSION,
|
||||
consentAcceptedAt: signedAt.toISOString(), signedAt: signedAt.toISOString(),
|
||||
source, signatureMode: 'PROFILE_REUSABLE', uploadedBy: principal.userId,
|
||||
};
|
||||
const signaturePayloadSha256 = sha256CanonicalJson(payload);
|
||||
try {
|
||||
await manager.query(`
|
||||
INSERT INTO inspection_act_signatures (
|
||||
id,act_id,signer_type,signer_user_id,signer_name,document_type,document_number,position,status,
|
||||
original_name,stored_name,mime_type,size_bytes,image_sha256,consent_text,consent_version,
|
||||
consent_accepted_at,client_signed_at,signed_at,latitude,longitude,accuracy_m,device_label,source,
|
||||
prepared_sha256,signature_payload_sha256,uploaded_by,created_at
|
||||
) VALUES (
|
||||
$1,$2,'INSPECTOR',$3,$4,NULL,NULL,'Inspector/a','SIGNED',
|
||||
$5,$6,$7,$8,$9,$10,$11,$12,NULL,$12,NULL,NULL,NULL,$13,$14,$15,$16,$3,$12
|
||||
)
|
||||
`, [
|
||||
id, actId, principal.userId, signerName, profile.originalName, storedName, profile.mimeType,
|
||||
profile.sizeBytes, profile.imageSha256, INSPECTOR_CONSENT, CONSENT_VERSION, signedAt,
|
||||
'Firma guardada en Mi perfil', source, preparedSha256, signaturePayloadSha256,
|
||||
]);
|
||||
await this.audit.record({
|
||||
...administrationAuditContext(principal, request),
|
||||
action: AuditAction.INSPECTION_ACT_SIGNATURE_RECORDED,
|
||||
entityType: 'inspection_act_signature',
|
||||
entityId: id,
|
||||
afterData: payload,
|
||||
metadata: { actId, immutable: true, signatureMode: 'PROFILE_REUSABLE' },
|
||||
}, manager);
|
||||
return { filePath };
|
||||
} catch (error) {
|
||||
await unlink(filePath).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async loadStoredSignature(manager: EntityManager, id: string): Promise<StoredSignature> {
|
||||
const [row] = await manager.query(`${this.signatureSelect()} WHERE signature.id=$1`, [id]) as StoredSignature[];
|
||||
if (!row) throw signatureNotFound();
|
||||
|
||||
@@ -1166,10 +1166,13 @@ export class InspectionFindingsService {
|
||||
principal: AuthPrincipal,
|
||||
): Promise<number> {
|
||||
const [row] = await manager.query(`
|
||||
UPDATE inspection_findings
|
||||
SET current_version = current_version + 1
|
||||
WHERE id = $1
|
||||
RETURNING current_version AS "versionNumber"
|
||||
WITH updated AS (
|
||||
UPDATE inspection_findings
|
||||
SET current_version = current_version + 1
|
||||
WHERE id = $1
|
||||
RETURNING current_version
|
||||
)
|
||||
SELECT current_version AS "versionNumber" FROM updated
|
||||
`, [finding.id]) as Array<{ versionNumber: number }>;
|
||||
const versionNumber = Number(row.versionNumber);
|
||||
finding.currentVersion = versionNumber;
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Matches,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
@@ -56,8 +55,4 @@ export class CreateFieldFindingDto {
|
||||
@Max(10)
|
||||
severity?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(optionalText)
|
||||
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||
correctionDueOn?: string | null;
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@ export class FieldFindingsService {
|
||||
customLegalBasis: dto.customLegalBasis ?? null,
|
||||
description: dto.description,
|
||||
severity: dto.severity,
|
||||
correctionDueOn: dto.correctionDueOn ?? null,
|
||||
correctionDueOn: null,
|
||||
};
|
||||
const finding = await this.findings.create(act.id, payload, principal, request);
|
||||
return {
|
||||
|
||||
@@ -295,6 +295,7 @@ export class FieldInventoryService {
|
||||
operationalAreaId: context.areaId,
|
||||
// Compatibility-only creation snapshot. Membership never depends on it.
|
||||
operatorCompanyId: context.companyId,
|
||||
inventoryFamilyId: dto.familyId ?? null,
|
||||
description: dto.description ?? null,
|
||||
discoveryNotes: dto.discoveryNotes ?? null,
|
||||
attributes: dto.attributes,
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export const API_VERSION = '0.29.0-4';
|
||||
export const API_PHASE = 'F6.2';
|
||||
export const API_VERSION = '0.29.0-7';
|
||||
export const API_PHASE = 'F6.7';
|
||||
|
||||
@@ -28,7 +28,8 @@ test('F3.2/F4 exige seleccionar el Acta explícitamente al crear Hallazgos desde
|
||||
test('F4 conserva el cierre documental inmutable de cada Acta como LOCKED y luego SEALED', () => {
|
||||
const closing = read('src/inspection-closing/inspection-closing.service.ts');
|
||||
assert.match(closing, /status='LOCKED'/);
|
||||
assert.match(closing, /INSPECTION_ACT_INSPECTOR_SIGNATURE_REQUIRED/);
|
||||
assert.match(closing, /INSPECTION_INSPECTOR_PROFILE_SIGNATURE_REQUIRED/);
|
||||
assert.match(closing, /createInspectorSignatureFromProfile/);
|
||||
assert.match(closing, /INSPECTION_ACT_COMPANY_OUTCOME_REQUIRED/);
|
||||
assert.match(closing, /finalSha256 = sha256CanonicalJson\(finalSnapshot\)/);
|
||||
assert.match(closing, /status='SEALED'/);
|
||||
|
||||
@@ -4,9 +4,9 @@ import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { API_PHASE, API_VERSION } from '../../src/version';
|
||||
|
||||
test('health metadata reports the current F6.1 release', () => {
|
||||
assert.equal(API_PHASE, 'F6.2');
|
||||
test('health metadata reports the current F6.7 release', () => {
|
||||
assert.equal(API_PHASE, 'F6.7');
|
||||
const pkg = JSON.parse(readFileSync(resolve(process.cwd(), 'package.json'), 'utf8')) as { version: string };
|
||||
assert.equal(API_VERSION, pkg.version);
|
||||
assert.equal(API_VERSION, '0.29.0-4');
|
||||
assert.equal(API_VERSION, '0.29.0-7');
|
||||
});
|
||||
@@ -80,9 +80,10 @@ test('F4 field finding flow only targets a draft act of the same in-progress ins
|
||||
assert.match(draftGuard, /WHERE visit_id = \$1::uuid[\s\S]*AND status = 'DRAFT'/);
|
||||
});
|
||||
|
||||
test('F4 sealing requires inspector signature and exactly one terminal company outcome', () => {
|
||||
test('F6.7 sealing copies the stored inspector signature and requires exactly one terminal company outcome', () => {
|
||||
const closeBody = closing.slice(closing.indexOf(' async close('), closing.indexOf(' async signatureContent('));
|
||||
assert.match(closeBody, /INSPECTION_ACT_INSPECTOR_SIGNATURE_REQUIRED/);
|
||||
assert.match(closeBody, /createInspectorSignatureFromProfile/);
|
||||
assert.match(closing, /INSPECTION_INSPECTOR_PROFILE_SIGNATURE_REQUIRED/);
|
||||
assert.match(closeBody, /companyOutcomes\.length !== 1/);
|
||||
assert.match(closeBody, /INSPECTION_ACT_COMPANY_OUTCOME_REQUIRED/);
|
||||
assert.match(closeBody, /ausencia documentada/);
|
||||
|
||||
@@ -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 = 33/);
|
||||
assert.match(gradle, /versionName = "0\.19\.5"/);
|
||||
assert.match(gradle, /versionCode = 37/);
|
||||
assert.match(gradle, /versionName = "0\.19\.9"/);
|
||||
assert.match(gradle, /https:\/\/dhv2\.korexlabs\.com\/api\/v3\//);
|
||||
assert.match(gradle, /applicationIdSuffix = "\.debug"/);
|
||||
});
|
||||
@@ -26,3 +26,18 @@ test('F5/F6.3 field inventory exposes Other families as reviewable choices to An
|
||||
assert.match(service, /AS "isOther"/);
|
||||
assert.match(service, /isOtherFamily: family\.isOther/);
|
||||
});
|
||||
|
||||
|
||||
test('field inventory persists the validated Tipo de instalación in the initial asset insert', () => {
|
||||
const fieldService = readFileSync(
|
||||
resolve(process.cwd(), 'src/inspection-visits/field-inventory.service.ts'),
|
||||
'utf8',
|
||||
);
|
||||
const assetsService = readFileSync(
|
||||
resolve(process.cwd(), 'src/asset-master/assets.service.ts'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
assert.match(fieldService, /inventoryFamilyId: dto\.familyId \?\? null/);
|
||||
assert.match(assetsService, /inventoryFamilyId: dto\.inventoryFamilyId \?\? null/);
|
||||
});
|
||||
|
||||
@@ -31,7 +31,8 @@ test('F6.1 uses contextual type catalog for Yacimiento and keeps OTROS/add-anoth
|
||||
assert.match(catalog, /finding_catalog_asset_overrides override/);
|
||||
assert.match(resolver, /code: 'OTHER'/);
|
||||
assert.match(resolver, /label: 'OTROS'/);
|
||||
assert.match(androidFinding, /OTROS · No está en el catálogo/);
|
||||
assert.match(androidFinding, /ExposedDropdownMenuBox/);
|
||||
assert.match(androidFinding, /Otro \/ No está en la lista/);
|
||||
assert.match(androidFinding, /También podés registrar otro Hallazgo sobre el mismo Inventario/);
|
||||
});
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ test('F6.1 presentation metadata keeps the visible WEB version aligned with pack
|
||||
const visibleVersion = version.match(/APP_VERSION\s*=\s*'([^']+)'/)?.[1];
|
||||
|
||||
assert.equal(visibleVersion, pkg.version);
|
||||
assert.match(version, /APP_PHASE\s*=\s*'F6\.2 · Firma por Acta y correo de usuario'/);
|
||||
assert.match(version, /APP_PHASE\s*=\s*'F6\.7 · Cierre y firma por Acta'/);
|
||||
});
|
||||
|
||||
test('F6.1 presentation keeps Relevamientos retired from WEB navigation and routes', () => {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
function source(path: string): string {
|
||||
return readFileSync(resolve(process.cwd(), path), 'utf8');
|
||||
}
|
||||
|
||||
test('F6.5 finding code CHECK accepts the current visible Acta code', () => {
|
||||
const migration = source('src/database/migrations/1790121000000-f6-5-field-finding-code-contract.ts');
|
||||
const service = source('src/inspection-findings/inspection-findings.service.ts');
|
||||
|
||||
assert.match(migration, /ACT-\[0-9\]\{5\}-\[0-9\]\{2\}-\[0-9\]\{2\}-\[0-9\]\{2\}/);
|
||||
assert.match(migration, /-H\[0-9\]\{3\}/);
|
||||
assert.match(service, /code: `\$\{act\.code\}-H\$\{String\(findingNumber\)\.padStart\(3, '0'\)\}`/);
|
||||
});
|
||||
|
||||
test('F6.5 mobile finding creation never asks the inspector for a correction date', () => {
|
||||
const dto = source('src/inspection-visits/dto/create-field-finding.dto.ts');
|
||||
const adapter = source('src/inspection-visits/field-findings.service.ts');
|
||||
const androidRequest = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/FieldFindingsMobile.kt');
|
||||
const androidScreen = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/FieldFindingScreen.kt');
|
||||
|
||||
assert.doesNotMatch(dto, /correctionDueOn/);
|
||||
assert.match(adapter, /correctionDueOn: null/);
|
||||
assert.doesNotMatch(androidRequest.match(/data class CreateFieldFindingRequest\([\s\S]*?\n\)/)?.[0] ?? '', /correctionDueOn/);
|
||||
assert.doesNotMatch(androidScreen, /Fecha de corrección/);
|
||||
assert.match(androidScreen, /ExposedDropdownMenuBox/);
|
||||
assert.match(androidScreen, /Tipo de Hallazgo \*/);
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
function source(path: string): string {
|
||||
return readFileSync(resolve(process.cwd(), path), 'utf8');
|
||||
}
|
||||
|
||||
test('F6.6 Hallazgo version capture uses a SELECT-shaped CTE so TypeORM never turns the version into NaN', () => {
|
||||
const service = source('src/inspection-findings/inspection-findings.service.ts');
|
||||
|
||||
assert.match(service, /WITH updated AS \(/);
|
||||
assert.match(service, /UPDATE inspection_findings/);
|
||||
assert.match(service, /RETURNING current_version/);
|
||||
assert.match(service, /SELECT current_version AS "versionNumber" FROM updated/);
|
||||
assert.match(service, /const versionNumber = Number\(row\.versionNumber\)/);
|
||||
});
|
||||
|
||||
test('F6.6 mobile home keeps Salir readable on narrow screens', () => {
|
||||
const home = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/MobileHomeScreen.kt');
|
||||
|
||||
assert.match(home, /Text\("Actualizar", maxLines = 1\)/);
|
||||
assert.match(home, /Text\("Salir", maxLines = 1\)/);
|
||||
assert.match(home, /modifier = Modifier\.weight\(1f\)/);
|
||||
});
|
||||
|
||||
test('F6.6 Hallazgos can receive as many field photos as needed', () => {
|
||||
const screen = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/FieldFindingScreen.kt');
|
||||
const model = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/MainViewModel.kt');
|
||||
|
||||
assert.match(screen, /Agregar foto con GPS/);
|
||||
assert.match(screen, /Agregar otra foto con GPS/);
|
||||
assert.match(screen, /Podés adjuntar todas las fotos que necesites/);
|
||||
assert.match(model, /loadEvidenceInternal\(findingId\)/);
|
||||
assert.doesNotMatch(screen, /máximo de 1 foto|solo una foto/i);
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
function source(path: string): string {
|
||||
return readFileSync(resolve(process.cwd(), path), 'utf8');
|
||||
}
|
||||
|
||||
const closing = source('src/inspection-closing/inspection-closing.service.ts');
|
||||
const users = source('src/administration/users/users.controller.ts');
|
||||
const userService = source('src/administration/users/users.service.ts');
|
||||
const migration = source('src/database/migrations/1790124600000-f6-7-user-reusable-signature.ts');
|
||||
const android = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernMobileActsScreen.kt');
|
||||
const labels = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/UiSpanishLabels.kt');
|
||||
const profile = source('../web-v2/src/pages/MyProfilePage.tsx');
|
||||
|
||||
test('F6.7 Acta lock and seal use SELECT-shaped version CTEs', () => {
|
||||
assert.doesNotMatch(closing, /RETURNING current_version AS "versionNumber"/);
|
||||
assert.equal((closing.match(/WITH updated AS \(/g) ?? []).length >= 2, true);
|
||||
assert.equal((closing.match(/SELECT current_version AS "versionNumber" FROM updated/g) ?? []).length >= 2, true);
|
||||
});
|
||||
test('F6.7 inspector signature is stored once in profile and copied into each sealed Acta', () => {
|
||||
assert.match(migration, /CREATE TABLE user_signature_profiles/);
|
||||
assert.match(migration, /image_data bytea NOT NULL/);
|
||||
assert.match(migration, /image_sha256 char\(64\) NOT NULL/);
|
||||
assert.match(users, /@Put\('self\/signature'\)/);
|
||||
assert.match(users, /@Get\('self\/signature\/content'\)/);
|
||||
assert.match(userService, /inspectInspectionSignatureFile/);
|
||||
assert.match(closing, /requireReusableInspectorSignature/);
|
||||
assert.match(closing, /createInspectorSignatureFromProfile/);
|
||||
assert.match(closing, /signatureMode: 'PROFILE_REUSABLE'/);
|
||||
});
|
||||
|
||||
test('F6.7 Android goes Draft to Para firmar to company manifestation without inspector drawing', () => {
|
||||
assert.match(labels, /"LOCKED" -> "Para firmar"/);
|
||||
assert.match(android, /Cerrar contenido y pasar a firma/);
|
||||
assert.match(android, /Firma del representante de la empresa \(acompañante\)/);
|
||||
assert.match(android, /Aplicar mi firma y cerrar Acta/);
|
||||
assert.doesNotMatch(android, /Firmá como inspector\/a/);
|
||||
assert.doesNotMatch(android, /inspectorSigned/);
|
||||
assert.doesNotMatch(android, /Text\(selected\.code/);
|
||||
assert.match(android, /companyOutcome\?\.status == "ABSENT"/);
|
||||
});
|
||||
test('F6.7 Dashboard exposes the reusable inspector signature clearly', () => {
|
||||
assert.match(profile, /FIRMA DEL INSPECTOR/);
|
||||
assert.match(profile, /Firma guardada para Actas/);
|
||||
assert.match(profile, /saveSelfSignature/);
|
||||
assert.match(profile, /Cada Acta conserva una copia propia de la firma/);
|
||||
});
|
||||
@@ -3,6 +3,7 @@ WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY tsconfig*.json vite.config.ts index.html ./
|
||||
COPY public ./public
|
||||
COPY src ./src
|
||||
RUN npm run build
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="referrer" content="strict-origin-when-cross-origin" />
|
||||
<link rel="icon" type="image/webp" href="/favicon.webp" />
|
||||
<title>DH Inspección V2</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "dhv2-web",
|
||||
"version": "0.23.0-3",
|
||||
"version": "0.23.0-4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "dhv2-web",
|
||||
"version": "0.23.0-3",
|
||||
"version": "0.23.0-4",
|
||||
"dependencies": {
|
||||
"maplibre-gl": "6.4.1",
|
||||
"react": "^19.0.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dhv2-web",
|
||||
"version": "0.23.0-3",
|
||||
"version": "0.23.0-4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 8.2 KiB |
@@ -1,2 +1,2 @@
|
||||
export const APP_VERSION = '0.23.0-3';
|
||||
export const APP_PHASE = 'F6.2 · Firma por Acta y correo de usuario';
|
||||
export const APP_VERSION = '0.23.0-4';
|
||||
export const APP_PHASE = 'F6.7 · Cierre y firma por Acta';
|
||||
|
||||
@@ -23,6 +23,15 @@ export interface UserSmtpSettings {
|
||||
custom: UserSmtpCustomSettings | null;
|
||||
}
|
||||
|
||||
|
||||
export interface UserReusableSignature {
|
||||
configured: boolean;
|
||||
mimeType: 'image/png' | null;
|
||||
sizeBytes: number | null;
|
||||
imageSha256: string | null;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
export interface UserSmtpSettingsInput {
|
||||
mode: UserSmtpMode;
|
||||
host?: string;
|
||||
@@ -65,3 +74,26 @@ export function testSelfSmtpSettings() {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
export function getSelfSignature() {
|
||||
return apiRequest<UserReusableSignature>('/users/self/signature');
|
||||
}
|
||||
|
||||
export function saveSelfSignature(file: File) {
|
||||
const body = new FormData();
|
||||
body.append('file', file);
|
||||
return apiRequest<UserReusableSignature>('/users/self/signature', {
|
||||
method: 'PUT',
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteSelfSignature() {
|
||||
return apiRequest<UserReusableSignature>('/users/self/signature', { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export function selfSignatureContentUrl(signature: UserReusableSignature) {
|
||||
const version = signature.imageSha256 ? `?v=${encodeURIComponent(signature.imageSha256)}` : '';
|
||||
return `/api/v3/users/self/signature/content${version}`;
|
||||
}
|
||||
|
||||
@@ -4,13 +4,18 @@ import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { initials } from '../lib/format';
|
||||
import {
|
||||
deleteSelfSignature,
|
||||
getSelfProfile,
|
||||
getSelfSignature,
|
||||
getSelfSmtpSettings,
|
||||
saveSelfSignature,
|
||||
saveSelfSmtpSettings,
|
||||
selfSignatureContentUrl,
|
||||
testSelfSmtpSettings,
|
||||
updateSelfProfile,
|
||||
} from '../lib/myProfileApi';
|
||||
import type {
|
||||
UserReusableSignature,
|
||||
UserSmtpMode,
|
||||
UserSmtpSecurityMode,
|
||||
UserSmtpSettings,
|
||||
@@ -21,6 +26,8 @@ export function MyProfilePage() {
|
||||
const { user } = useAuth();
|
||||
const [profile, setProfile] = useState<AdministrativeUserProfile | null>(null);
|
||||
const [smtp, setSmtp] = useState<UserSmtpSettings | null>(null);
|
||||
const [signature, setSignature] = useState<UserReusableSignature | null>(null);
|
||||
const [signatureFile, setSignatureFile] = useState<File | null>(null);
|
||||
const [mode, setMode] = useState<UserSmtpMode>('SYSTEM');
|
||||
const [host, setHost] = useState('');
|
||||
const [port, setPort] = useState(587);
|
||||
@@ -47,12 +54,14 @@ export function MyProfilePage() {
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [loadedProfile, loadedSmtp] = await Promise.all([
|
||||
const [loadedProfile, loadedSmtp, loadedSignature] = await Promise.all([
|
||||
getSelfProfile(),
|
||||
getSelfSmtpSettings(),
|
||||
getSelfSignature(),
|
||||
]);
|
||||
setProfile(loadedProfile);
|
||||
applySmtp(loadedSmtp);
|
||||
setSignature(loadedSignature);
|
||||
} catch (requestError) {
|
||||
setError(errorMessage(requestError));
|
||||
} finally {
|
||||
@@ -82,6 +91,35 @@ export function MyProfilePage() {
|
||||
}
|
||||
};
|
||||
|
||||
const saveSignature = async () => {
|
||||
if (!signatureFile) return;
|
||||
setError(''); setSuccess(''); setSaving('signature');
|
||||
try {
|
||||
const updated = await saveSelfSignature(signatureFile);
|
||||
setSignature(updated);
|
||||
setSignatureFile(null);
|
||||
setSuccess('Firma del inspector guardada. Se aplicará automáticamente al cerrar cada Acta.');
|
||||
} catch (requestError) {
|
||||
setError(errorMessage(requestError));
|
||||
} finally {
|
||||
setSaving('');
|
||||
}
|
||||
};
|
||||
|
||||
const clearSignature = async () => {
|
||||
setError(''); setSuccess(''); setSaving('signature-delete');
|
||||
try {
|
||||
const updated = await deleteSelfSignature();
|
||||
setSignature(updated);
|
||||
setSignatureFile(null);
|
||||
setSuccess('Firma guardada eliminada.');
|
||||
} catch (requestError) {
|
||||
setError(errorMessage(requestError));
|
||||
} finally {
|
||||
setSaving('');
|
||||
}
|
||||
};
|
||||
|
||||
const saveSmtp = async () => {
|
||||
setError(''); setSuccess(''); setSaving('smtp');
|
||||
try {
|
||||
@@ -116,7 +154,9 @@ export function MyProfilePage() {
|
||||
};
|
||||
|
||||
if (loading) return <LoadingBlock label="Cargando tu perfil…" />;
|
||||
if (!profile || !smtp) return <Alert>{error || 'No se pudo cargar el perfil.'}</Alert>;
|
||||
if (!profile || !smtp || !signature) return <Alert>{error || 'No se pudo cargar el perfil.'}</Alert>;
|
||||
|
||||
const isInspector = profile.roles.some((role) => role.code === 'inspector');
|
||||
|
||||
return <section>
|
||||
<div className="page-heading user-heading">
|
||||
@@ -184,6 +224,24 @@ export function MyProfilePage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isInspector && <div className="panel form-panel" style={{ marginTop: 16 }}>
|
||||
<div className="panel-heading">
|
||||
<div><span className="eyebrow">FIRMA DEL INSPECTOR</span><h2>Firma guardada para Actas</h2></div>
|
||||
</div>
|
||||
<p className="muted-copy">Cargala una sola vez. Cuando cierres un Acta desde la APK, el contenido quedará en estado <strong>Para firmar</strong>; después firma el acompañante y, al finalizar, el sistema incorpora automáticamente esta firma del inspector al ejemplar inmutable.</p>
|
||||
{signature.configured && <div className="signature-profile-preview">
|
||||
<img src={selfSignatureContentUrl(signature)} alt="Firma guardada del inspector" />
|
||||
<div><strong>Firma configurada</strong><small>PNG · {Math.max(1, Math.round((signature.sizeBytes ?? 0) / 1024))} KB</small></div>
|
||||
</div>}
|
||||
<div className="form-grid" style={{ marginTop: 14 }}>
|
||||
<label className="field"><span>{signature.configured ? 'Reemplazar firma PNG' : 'Firma PNG *'}</span><input type="file" accept="image/png" onChange={(event) => setSignatureFile(event.target.files?.[0] ?? null)} /></label>
|
||||
</div>
|
||||
<p className="muted-copy">La imagen debe ser PNG y pesar como máximo 1 MB. Cada Acta conserva una copia propia de la firma y su SHA-256; cambiar la firma del perfil no altera Actas ya cerradas.</p>
|
||||
<div className="form-actions wrap-actions">
|
||||
<button className="button primary" type="button" onClick={saveSignature} disabled={!signatureFile || saving === 'signature'}>{saving === 'signature' ? 'Guardando…' : signature.configured ? 'Reemplazar firma' : 'Guardar firma'}</button>
|
||||
{signature.configured && <button className="button secondary" type="button" onClick={clearSignature} disabled={saving === 'signature-delete'}>{saving === 'signature-delete' ? 'Eliminando…' : 'Eliminar firma guardada'}</button>}
|
||||
</div>
|
||||
</div>}
|
||||
<div className="panel" style={{ marginTop: 16 }}>
|
||||
<div className="panel-heading"><div><span className="eyebrow">SEGURIDAD</span><h2>Cuenta</h2></div></div>
|
||||
<p>Tu email es obligatorio para operar en DH Inspección. La contraseña SMTP nunca se muestra ni se incluye en auditorías.</p>
|
||||
|
||||
@@ -1350,3 +1350,30 @@ code { color: #5e6677; font-family: ui-monospace, monospace; font-size: 9px; }
|
||||
cursor: default;
|
||||
opacity: .65;
|
||||
}
|
||||
|
||||
/* F6.7 · firma reutilizable del inspector */
|
||||
.signature-profile-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
max-width: 620px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
background: #f7faf9;
|
||||
}
|
||||
.signature-profile-preview img {
|
||||
width: min(240px, 46%);
|
||||
height: 88px;
|
||||
object-fit: contain;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
border: 1px solid #e3e7e5;
|
||||
}
|
||||
.signature-profile-preview strong,
|
||||
.signature-profile-preview small { display: block; }
|
||||
.signature-profile-preview small { margin-top: 4px; color: var(--muted); }
|
||||
@media (max-width: 640px) {
|
||||
.signature-profile-preview { align-items: flex-start; flex-direction: column; }
|
||||
.signature-profile-preview img { width: 100%; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user